mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Merge pull request #767 from hneogy/fix/alpha5-norad-id
fix(satellites): decode Alpha-5 catalog numbers in the layer and the satellite tools
This commit is contained in:
8 files changed
+216
-8
No files matched your search
@@ -0,0 +1,11 @@
|
|||||||
|
- Read satellite catalog numbers written in Alpha-5, the TLE spelling of
|
||||||
|
numbers above 99999: a letter for the first two digits, skipping I and O
|
||||||
|
(`A0000` is 100000, `Z9999` is 339999). The satellites layer converted such a
|
||||||
|
field to `NaN`, so every Alpha-5 satellite shared one catalog entry and all
|
||||||
|
but the first were dropped as duplicates; the satellite tools read the same
|
||||||
|
field as no number, so such a satellite could not be found by it. The layer
|
||||||
|
and the tools now decode the field in `src/sources/tle.js`, and reject a
|
||||||
|
malformed one (a sign, decimal point, hex, embedded space or stray letter)
|
||||||
|
instead of reading part of it as a number. This is Alpha-5 TLE
|
||||||
|
compatibility: CelesTrak's TLE feed carries no satellite above 99999, which
|
||||||
|
needs the OMM feed (#1191) (Honorius Neogy, #767, fixes #751).
|
||||||
@@ -4324,6 +4324,7 @@ silently demoting every later lookup for the session.
|
|||||||
- **The DENSE chip reports the dense LOAD, not the catalog param.** The param flips synchronously while the Starlink shell takes seconds to arrive over a chunked load, and CelesTrak 502s that feed regularly. So the chip reads `DENSE ···` (busy, disabled) while loading, ACTIVE only once dense points are actually on screen, and `DENSE ✕` with the reason on hover when the load fails — a failure also reverts `catalog` to `core`, drops any partial chunk, and leaves the chip clickable to retry. A load is judged by points added, not by HTTP status: a 200 carrying an empty body, a passed-through HTML error page, or only TLEs the core catalog already owns fails with the same revert semantics as a 502. Any explicit request for `core` clears a latched error even when the mode does not change, so a Space Missions restore of an already-core snapshot never leaves the user with a failure they did not cause. Because the load settles asynchronously, the layer pushes a re-render through the optional `setRowControlsListener()` hook; nothing else would repaint that row before the 5-minute catalog refresh, so the count and legend would otherwise sit stale.
|
- **The DENSE chip reports the dense LOAD, not the catalog param.** The param flips synchronously while the Starlink shell takes seconds to arrive over a chunked load, and CelesTrak 502s that feed regularly. So the chip reads `DENSE ···` (busy, disabled) while loading, ACTIVE only once dense points are actually on screen, and `DENSE ✕` with the reason on hover when the load fails — a failure also reverts `catalog` to `core`, drops any partial chunk, and leaves the chip clickable to retry. A load is judged by points added, not by HTTP status: a 200 carrying an empty body, a passed-through HTML error page, or only TLEs the core catalog already owns fails with the same revert semantics as a 502. Any explicit request for `core` clears a latched error even when the mode does not change, so a Space Missions restore of an already-core snapshot never leaves the user with a failure they did not cause. Because the load settles asynchronously, the layer pushes a re-render through the optional `setRowControlsListener()` hook; nothing else would repaint that row before the 5-minute catalog refresh, so the count and legend would otherwise sit stale.
|
||||||
- **A dependency owner takes the row with it.** Space Missions borrows this layer for TLE lookup with `showPoints:false`; while points are hidden the layer returns empty row controls, so the legend never describes an empty sky and the chip cannot accept a write that the owner's restore would silently revert.
|
- **A dependency owner takes the row with it.** Space Missions borrows this layer for TLE lookup with `showPoints:false`; while points are hidden the layer returns empty row controls, so the legend never describes an empty sky and the chip cannot accept a write that the owner's restore would silently revert.
|
||||||
- The detection-overlay record cache (`_detectionObjects`) is cleared with the catalog on every rebuild: it stamps id/class at creation only, and a rebuild can re-tag a satellite when a partial CelesTrak outage changes which group wins dedupe.
|
- The detection-overlay record cache (`_detectionObjects`) is cleared with the catalog on every rebuild: it stamps id/class at creation only, and a rebuild can re-tag a satellite when a partial CelesTrak outage changes which group wins dedupe.
|
||||||
|
- **Catalog numbers above 99999** can be written in a TLE as Alpha-5: a letter A–Z, skipping I and O, for the first two digits, so `A0000` is 100000 and `Z9999` is 339999. CelesTrak's TLE feed carries no satellite above 99999 (that needs the OMM feed, #1191), so this is compatibility with Alpha-5 TLEs. `src/sources/tle.js` decodes the field through one shared helper: `noradIdFromSatnum()` for the satellites layer, which keys and deduplicates its catalog by that number, and `tleCatalogNumber()` for the satellite tools. Plain digits read as before (`noradIdFromSatnum()` accepts any length, as an OMM satrec carries the number unpadded); a malformed field — a sign, decimal point, hex, exponent, embedded space, stray or lowercase letter, or blank — is rejected rather than partly read, as `NaN` in the layer and `null` in the tools.
|
||||||
- **CCTV estimated bearings:** a camera whose pack marks `headingConfidence: 'low'` (the id-hash
|
- **CCTV estimated bearings:** a camera whose pack marks `headingConfidence: 'low'` (the id-hash
|
||||||
`fallbackHeadingFromId` bearing) shows `HDG n° (ESTIMATED)` in the HUD and draws its coverage
|
`fallbackHeadingFromId` bearing) shows `HDG n° (ESTIMATED)` in the HUD and draws its coverage
|
||||||
wireframe dashed; colours, widths and active/idle emphasis are unchanged. A manual calibration
|
wireframe dashed; colours, widths and active/idle emphasis are unchanged. A manual calibration
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { twoline2satrec } from 'satellite.js';
|
import { twoline2satrec } from 'satellite.js';
|
||||||
|
import { noradIdFromSatnum } from '../../sources/tle.js';
|
||||||
import {
|
import {
|
||||||
DENSE_REFRESH_FRAMES,
|
DENSE_REFRESH_FRAMES,
|
||||||
DENSE_GROUP_PATH,
|
DENSE_GROUP_PATH,
|
||||||
@@ -113,7 +114,7 @@ export function createCatalog({ state: layerState, services, parts, source }) {
|
|||||||
const entry = entries[i];
|
const entry = entries[i];
|
||||||
const satrec = twoline2satrec(entry.line1, entry.line2);
|
const satrec = twoline2satrec(entry.line1, entry.line2);
|
||||||
if (!satrec || satrec.error !== 0) continue;
|
if (!satrec || satrec.error !== 0) continue;
|
||||||
const noradId = Number(satrec.satnum);
|
const noradId = noradIdFromSatnum(satrec.satnum);
|
||||||
if (layerState._catalog.has(noradId)) continue; // core catalog keeps priority
|
if (layerState._catalog.has(noradId)) continue; // core catalog keeps priority
|
||||||
const pos = parts.orbits.propagatePosition(satrec, now);
|
const pos = parts.orbits.propagatePosition(satrec, now);
|
||||||
if (!pos) continue;
|
if (!pos) continue;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { twoline2satrec } from 'satellite.js';
|
import { twoline2satrec } from 'satellite.js';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { msg } from '../../i18n/index.js';
|
import { msg } from '../../i18n/index.js';
|
||||||
|
import { noradIdFromSatnum } from '../../sources/tle.js';
|
||||||
import { CATALOG_GROUPS, ISS_NORAD, POINT_STYLES } from './policy.js';
|
import { CATALOG_GROUPS, ISS_NORAD, POINT_STYLES } from './policy.js';
|
||||||
|
|
||||||
export function createIngestion({
|
export function createIngestion({
|
||||||
@@ -111,7 +112,7 @@ export function createIngestion({
|
|||||||
const satrec = twoline2satrec(entry.line1, entry.line2);
|
const satrec = twoline2satrec(entry.line1, entry.line2);
|
||||||
if (!satrec || satrec.error !== 0) continue;
|
if (!satrec || satrec.error !== 0) continue;
|
||||||
|
|
||||||
const noradId = Number(satrec.satnum);
|
const noradId = noradIdFromSatnum(satrec.satnum);
|
||||||
if (seen.has(noradId)) continue;
|
if (seen.has(noradId)) continue;
|
||||||
seen.add(noradId);
|
seen.add(noradId);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { parseTleText } from '../../sources/tle.js';
|
||||||
|
import { createIngestion } from './ingestion.js';
|
||||||
|
|
||||||
|
// The ISS set (1 January 2024) with its catalog field swapped per satellite.
|
||||||
|
const ISS_LINE1 =
|
||||||
|
'1 25544U 98067A 24001.50000000 .00016717 00000-0 30370-3 0 9993';
|
||||||
|
const ISS_LINE2 =
|
||||||
|
'2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.50377579432323';
|
||||||
|
|
||||||
|
function tleSet(name, field) {
|
||||||
|
return [
|
||||||
|
name,
|
||||||
|
ISS_LINE1.replace('25544', field),
|
||||||
|
ISS_LINE2.replace('25544', field),
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function ingestionFor(catalogText) {
|
||||||
|
const state = {
|
||||||
|
_trackingRefreshEpoch: 0,
|
||||||
|
_activeUpdateControllers: new Set(),
|
||||||
|
_pointCollection: {
|
||||||
|
removeAll() {},
|
||||||
|
add: (point) => point,
|
||||||
|
},
|
||||||
|
_points: new Map(),
|
||||||
|
_orbitPaths: new Map(),
|
||||||
|
_catalog: new Map(),
|
||||||
|
_detectionObjects: new Map(),
|
||||||
|
_denseLoadToken: 0,
|
||||||
|
_catalogRevision: 0,
|
||||||
|
_params: { catalog: 'core', showOrbits: false },
|
||||||
|
};
|
||||||
|
const parts = {
|
||||||
|
orbits: {
|
||||||
|
parseTLE: parseTleText,
|
||||||
|
propagatePosition: () => ({ longitude: 0, latitude: 0, altitude: 4e5 }),
|
||||||
|
},
|
||||||
|
labels: { _syncIssOverlay() {} },
|
||||||
|
controls: { _pointStyleFor: () => ({}) },
|
||||||
|
rendering: { _showOrbitPath() {} },
|
||||||
|
tracking: {
|
||||||
|
async _reconcileTrackedSubjectContext() {},
|
||||||
|
_applyPendingTrackingRestore() {},
|
||||||
|
},
|
||||||
|
catalog: {},
|
||||||
|
};
|
||||||
|
const source = {
|
||||||
|
async readGroup() {
|
||||||
|
return { ok: true, text: catalogText };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const { methods } = createIngestion({ state, services: {}, parts, source });
|
||||||
|
return { state, update: () => methods.update({ scene: {} }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('every Alpha-5 satellite keeps its own catalog entry through dedupe', async (t) => {
|
||||||
|
t.mock.method(console, 'log', () => {});
|
||||||
|
t.mock.method(console, 'warn', () => {});
|
||||||
|
const { state, update } = ingestionFor(
|
||||||
|
[
|
||||||
|
tleSet('FIRST', 'A0000'),
|
||||||
|
tleSet('SECOND', 'B0001'),
|
||||||
|
tleSet('THIRD', 'Z9999'),
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
await update();
|
||||||
|
assert.deepEqual(
|
||||||
|
[...state._catalog].map(([id, entry]) => [id, entry.name]),
|
||||||
|
[
|
||||||
|
[100000, 'FIRST'],
|
||||||
|
[110001, 'SECOND'],
|
||||||
|
[339999, 'THIRD'],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert.deepEqual([...state._points.keys()], [100000, 110001, 339999]);
|
||||||
|
assert.equal(state._count, 3);
|
||||||
|
});
|
||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
twoline2satrec,
|
twoline2satrec,
|
||||||
} from 'satellite.js';
|
} from 'satellite.js';
|
||||||
import { findNextSatellitePass } from '../../data/satellitePass.js';
|
import { findNextSatellitePass } from '../../data/satellitePass.js';
|
||||||
import { parseTleText } from '../../sources/tle.js';
|
import { noradIdFromSatnum, parseTleText } from '../../sources/tle.js';
|
||||||
import { ORBIT_PATH_STEPS, ISS_NORAD } from './policy.js';
|
import { ORBIT_PATH_STEPS, ISS_NORAD } from './policy.js';
|
||||||
|
|
||||||
export function createOrbits({ state: layerState, services, parts, source }) {
|
export function createOrbits({ state: layerState, services, parts, source }) {
|
||||||
@@ -284,7 +284,7 @@ export function createOrbits({ state: layerState, services, parts, source }) {
|
|||||||
const current = propagatePosition(satrec, referenceDate);
|
const current = propagatePosition(satrec, referenceDate);
|
||||||
if (!current) return null;
|
if (!current) return null;
|
||||||
return {
|
return {
|
||||||
noradId: Number(satrec.satnum),
|
noradId: noradIdFromSatnum(satrec.satnum),
|
||||||
name: String(name || '').trim(),
|
name: String(name || '').trim(),
|
||||||
current,
|
current,
|
||||||
periodSec: orbitalPeriodSeconds(satrec),
|
periodSec: orbitalPeriodSeconds(satrec),
|
||||||
|
|||||||
+42
-3
@@ -21,8 +21,47 @@ export function parseTleText(text) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The NORAD catalog number from TLE line 1, or null. */
|
// Alpha-5: a letter A–Z, skipping I and O, then exactly four digits.
|
||||||
|
const ALPHA5 = /^[A-HJ-NP-Z]\d{4}$/;
|
||||||
|
const DIGITS = /^\d+$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The catalog number a catalog field stands for, or null for any other field.
|
||||||
|
*
|
||||||
|
* A field is either plain digits or Alpha-5, the TLE spelling of numbers above
|
||||||
|
* 99999: the letter stands for 10–33 in the ten-thousands place, skipping I
|
||||||
|
* and O, so 100000 is "A0000" and 339999 is "Z9999". Anything else (a sign, a
|
||||||
|
* decimal point, hex, an embedded space, a lowercase or trailing letter) is
|
||||||
|
* not a catalog number.
|
||||||
|
* @param {string} field the field with any leading spaces already removed
|
||||||
|
* @returns {number | null}
|
||||||
|
*/
|
||||||
|
function decodeCatalogField(field) {
|
||||||
|
if (DIGITS.test(field)) return Number(field);
|
||||||
|
if (!ALPHA5.test(field)) return null;
|
||||||
|
const letter = field[0];
|
||||||
|
let tens = letter.charCodeAt(0) - 65 + 10;
|
||||||
|
if (letter > 'I') tens -= 1;
|
||||||
|
if (letter > 'O') tens -= 1;
|
||||||
|
return tens * 10000 + Number(field.slice(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NORAD catalog number of a satrec as a number, or NaN.
|
||||||
|
*
|
||||||
|
* satellite.js keeps the TLE's five-character field verbatim in
|
||||||
|
* `satrec.satnum`, and `Number("A0000")` is NaN, so every Alpha-5 satellite
|
||||||
|
* would share one NaN key. Digits of any length (an OMM satrec carries the
|
||||||
|
* number unpadded) and Alpha-5 decode; anything else, blank included, is NaN.
|
||||||
|
* @param {string} satnum
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
export function noradIdFromSatnum(satnum) {
|
||||||
|
return decodeCatalogField(String(satnum ?? '').trim()) ?? Number.NaN;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The NORAD catalog number from TLE line 1 (columns 3–7), or null. */
|
||||||
export function tleCatalogNumber(line1) {
|
export function tleCatalogNumber(line1) {
|
||||||
const number = Number.parseInt(String(line1).slice(2, 7), 10);
|
const field = String(line1).slice(2, 7);
|
||||||
return Number.isInteger(number) ? number : null;
|
return decodeCatalogField(field.replace(/^ +/, ''));
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
import { twoline2satrec } from 'satellite.js';
|
||||||
import { composeCatalog, coreTools } from '../index.js';
|
import { composeCatalog, coreTools } from '../index.js';
|
||||||
import { parseTleText, tleCatalogNumber } from '../../sources/tle.js';
|
import {
|
||||||
|
noradIdFromSatnum,
|
||||||
|
parseTleText,
|
||||||
|
tleCatalogNumber,
|
||||||
|
} from '../../sources/tle.js';
|
||||||
|
|
||||||
// Element sets published for 1 January 2024.
|
// Element sets published for 1 January 2024.
|
||||||
const TLE = `ISS (ZARYA)
|
const TLE = `ISS (ZARYA)
|
||||||
@@ -51,6 +56,76 @@ for (const missingLine of [1, 2]) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test('Alpha-5 catalog numbers decode, skipping I and O', () => {
|
||||||
|
for (const [field, id] of [
|
||||||
|
['A0000', 100000],
|
||||||
|
['A0404', 100404],
|
||||||
|
['H9999', 179999],
|
||||||
|
['J0000', 180000],
|
||||||
|
['N9999', 229999],
|
||||||
|
['P0000', 230000],
|
||||||
|
['T0449', 270449],
|
||||||
|
['Z9999', 339999],
|
||||||
|
]) {
|
||||||
|
assert.equal(noradIdFromSatnum(field), id, field);
|
||||||
|
assert.equal(tleCatalogNumber(`1 ${field}U`), id, field);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a digit catalog field reads as it did before', () => {
|
||||||
|
assert.equal(noradIdFromSatnum('25544'), 25544);
|
||||||
|
assert.equal(noradIdFromSatnum('00404'), 404);
|
||||||
|
assert.equal(noradIdFromSatnum(' 404'), 404);
|
||||||
|
assert.equal(tleCatalogNumber('1 25544U'), 25544);
|
||||||
|
assert.equal(tleCatalogNumber('1 404U'), 404);
|
||||||
|
// An OMM satrec carries the number unpadded, past five digits.
|
||||||
|
assert.equal(noradIdFromSatnum('100000'), 100000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a malformed catalog field is no number', () => {
|
||||||
|
for (const field of [
|
||||||
|
'I0000',
|
||||||
|
'O0000',
|
||||||
|
'a0404',
|
||||||
|
'A00',
|
||||||
|
'2554A',
|
||||||
|
'255 4',
|
||||||
|
'-1234',
|
||||||
|
'0x1F0',
|
||||||
|
'1.5',
|
||||||
|
'1e3',
|
||||||
|
'',
|
||||||
|
' ',
|
||||||
|
]) {
|
||||||
|
assert.ok(Number.isNaN(noradIdFromSatnum(field)), JSON.stringify(field));
|
||||||
|
assert.equal(
|
||||||
|
tleCatalogNumber(`1 ${field.padEnd(5)}U`),
|
||||||
|
null,
|
||||||
|
JSON.stringify(field),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.ok(Number.isNaN(noradIdFromSatnum(undefined)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a satellite numbered above 99999 keeps its number and is found by it', async () => {
|
||||||
|
// The ISS set with its catalog field in Alpha-5: A0000 is 100000.
|
||||||
|
const text = TLE.replaceAll('25544', 'A0000');
|
||||||
|
const [iss] = parseTleText(text);
|
||||||
|
const { satnum } = twoline2satrec(iss.line1, iss.line2);
|
||||||
|
assert.ok(Number.isNaN(Number(satnum))); // what the catalog builders used
|
||||||
|
assert.equal(noradIdFromSatnum(satnum), 100000);
|
||||||
|
const catalog = composeCatalog({
|
||||||
|
tools: coreTools,
|
||||||
|
services: { satellites: satellites(text), clock: CLOCK },
|
||||||
|
});
|
||||||
|
const result = await catalog.call('next_satellite_pass', {
|
||||||
|
location: { lat: 40.7, lon: -74 },
|
||||||
|
satellite: '100000',
|
||||||
|
});
|
||||||
|
assert.equal(result.data.satellite, 'ISS (ZARYA)');
|
||||||
|
assert.equal(result.data.norad, 100000);
|
||||||
|
});
|
||||||
|
|
||||||
test('the next pass defaults to the ISS and reports times, peak and direction', async () => {
|
test('the next pass defaults to the ISS and reports times, peak and direction', async () => {
|
||||||
const catalog = composeCatalog({
|
const catalog = composeCatalog({
|
||||||
tools: coreTools,
|
tools: coreTools,
|
||||||
|
|||||||
Reference in new issue
Block a user