From ac62542641d47f06330f56398b2d0e5f75a9b041 Mon Sep 17 00:00:00 2001 From: Honorius Neogy Date: Sat, 3 Oct 2026 15:03:31 -0400 Subject: [PATCH] fix(satellites): decode Alpha-5 catalog numbers in the shared TLE module Catalog numbers above 99999 arrive in a TLE as Alpha-5: a letter A-Z, skipping I and O, stands for 10-33 in the ten-thousands place, so 100000 is A0000. satellite.js keeps the field as the string satrec.satnum, and the three catalog builders converted it with Number(), which gives NaN; the Set and Map used to deduplicate and store treat NaN as equal to NaN, so the first such satellite was stored under NaN and every later one was dropped as a duplicate (#751). The satellite tools read the same field with parseInt and reported no number, so such a satellite could not be found by it. The decoder lives in src/sources/tle.js, beside parseTleText, which the layer and the tools both import already: noradIdFromSatnum() for the three builders, and tleCatalogNumber() decodes through the same function. Every other field reads as it did before: Number() in the layer, parseInt or null in the tools. --- src/layers/satellites/catalog.js | 3 +- src/layers/satellites/ingestion.js | 3 +- src/layers/satellites/orbits.js | 4 +-- src/sources/tle.js | 44 ++++++++++++++++++++++++- src/tools/queries/space.test.mjs | 52 +++++++++++++++++++++++++++++- 5 files changed, 100 insertions(+), 6 deletions(-) diff --git a/src/layers/satellites/catalog.js b/src/layers/satellites/catalog.js index 25eaf9b2c..fd136c82d 100644 --- a/src/layers/satellites/catalog.js +++ b/src/layers/satellites/catalog.js @@ -1,5 +1,6 @@ import * as Cesium from 'cesium'; import { twoline2satrec } from 'satellite.js'; +import { noradIdFromSatnum } from '../../sources/tle.js'; import { DENSE_REFRESH_FRAMES, DENSE_GROUP_PATH, @@ -113,7 +114,7 @@ export function createCatalog({ state: layerState, services, parts, source }) { const entry = entries[i]; const satrec = twoline2satrec(entry.line1, entry.line2); 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 const pos = parts.orbits.propagatePosition(satrec, now); if (!pos) continue; diff --git a/src/layers/satellites/ingestion.js b/src/layers/satellites/ingestion.js index 7fb36bb00..14e464892 100644 --- a/src/layers/satellites/ingestion.js +++ b/src/layers/satellites/ingestion.js @@ -1,5 +1,6 @@ import { twoline2satrec } from 'satellite.js'; import * as Cesium from 'cesium'; +import { noradIdFromSatnum } from '../../sources/tle.js'; import { CATALOG_GROUPS, ISS_NORAD, POINT_STYLES } from './policy.js'; export function createIngestion({ @@ -108,7 +109,7 @@ export function createIngestion({ const satrec = twoline2satrec(entry.line1, entry.line2); if (!satrec || satrec.error !== 0) continue; - const noradId = Number(satrec.satnum); + const noradId = noradIdFromSatnum(satrec.satnum); if (seen.has(noradId)) continue; seen.add(noradId); diff --git a/src/layers/satellites/orbits.js b/src/layers/satellites/orbits.js index 32bb57cf9..0e4461e85 100644 --- a/src/layers/satellites/orbits.js +++ b/src/layers/satellites/orbits.js @@ -8,7 +8,7 @@ import { twoline2satrec, } from 'satellite.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'; 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); if (!current) return null; return { - noradId: Number(satrec.satnum), + noradId: noradIdFromSatnum(satrec.satnum), name: String(name || '').trim(), current, periodSec: orbitalPeriodSeconds(satrec), diff --git a/src/sources/tle.js b/src/sources/tle.js index ac7aedc2c..f5fad48bd 100644 --- a/src/sources/tle.js +++ b/src/sources/tle.js @@ -21,8 +21,50 @@ export function parseTleText(text) { return result; } +/** + * The catalog number an Alpha-5 field stands for, or null for any other field. + * + * Since the catalog passed 99999 (July 2026) a TLE writes the numbers above it + * in Alpha-5: a letter A–Z, skipping I and O, stands for 10–33 in the + * ten-thousands place, so 100000 is "A0000" and 339999 is "Z9999". + */ +function alpha5CatalogNumber(field) { + const letter = field[0]; + if ( + letter >= 'A' && + letter <= 'Z' && + letter !== 'I' && + letter !== 'O' && + /^\d{4}$/.test(field.slice(1)) + ) { + let tens = letter.charCodeAt(0) - 65 + 10; + if (letter > 'I') tens -= 1; + if (letter > 'O') tens -= 1; + return tens * 10000 + Number(field.slice(1)); + } + return null; +} + +/** + * NORAD catalog number of a satrec as a number. + * + * 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. An Alpha-5 field decodes to its number; a + * five-digit field is unchanged; anything else stays NaN. + * @param {string} satnum + * @returns {number} + */ +export function noradIdFromSatnum(satnum) { + const field = String(satnum ?? '').trim(); + return alpha5CatalogNumber(field) ?? Number(field); +} + /** The NORAD catalog number from TLE line 1, or null. */ export function tleCatalogNumber(line1) { - const number = Number.parseInt(String(line1).slice(2, 7), 10); + const field = String(line1).slice(2, 7); + const alpha5 = alpha5CatalogNumber(field); + if (alpha5 !== null) return alpha5; + const number = Number.parseInt(field, 10); return Number.isInteger(number) ? number : null; } diff --git a/src/tools/queries/space.test.mjs b/src/tools/queries/space.test.mjs index f6c2a8df8..5ff267ce1 100644 --- a/src/tools/queries/space.test.mjs +++ b/src/tools/queries/space.test.mjs @@ -1,7 +1,12 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { twoline2satrec } from 'satellite.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. const TLE = `ISS (ZARYA) @@ -51,6 +56,51 @@ 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('every other catalog field reads as it did before', () => { + assert.equal(noradIdFromSatnum('25544'), 25544); + assert.equal(noradIdFromSatnum('00404'), 404); + assert.equal(noradIdFromSatnum(''), 0); // as Number('') did + for (const field of ['I0000', 'O0000', 'a0404', 'A00']) { + assert.ok(Number.isNaN(noradIdFromSatnum(field)), field); + assert.equal(tleCatalogNumber(`1 ${field}U`), null, field); + } +}); + +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 () => { const catalog = composeCatalog({ tools: coreTools,