From ac62542641d47f06330f56398b2d0e5f75a9b041 Mon Sep 17 00:00:00 2001 From: Honorius Neogy Date: Sat, 3 Oct 2026 15:03:31 -0400 Subject: [PATCH 1/3] 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, From 6b70392f3fa8d0c984c8cbf693f2485263bc575d Mon Sep 17 00:00:00 2001 From: Honorius Neogy Date: Sat, 3 Oct 2026 15:03:32 -0400 Subject: [PATCH 2/3] docs: note Alpha-5 catalog numbers in the changelog and current state --- CHANGELOG.md | 8 ++++++++ docs/CURRENT-STATE.md | 1 + 2 files changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c885e2caa..213563788 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -386,6 +386,14 @@ needs only the Map Tiles API and the Maps JavaScript API (Street View). Keyless sessions and the Photon/Nominatim fallback order are unchanged. +- Read satellite catalog numbers above 99999. A TLE writes them in Alpha-5, + a letter for the first two digits (`A0000` is 100000), which the satellites + layer converted to `NaN`: every such 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` + (Honorius Neogy, #767, fixes #751). + ## [0.2.1] — 2026-10-02 — God's Eye View through Your Agent (with security fixes and other features) - Ship each bundled data pack once. The region, marine, admin-boundary, diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 2cc281812..15a4ec999 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4139,6 +4139,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. - **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. +- **Catalog numbers above 99999** arrive 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. `src/sources/tle.js` decodes the field: `noradIdFromSatnum()` for the satellites layer, which keys and deduplicates its catalog by that number, and `tleCatalogNumber()` for the satellite tools. A five-digit field reads as before; a field that is neither stays `NaN` in the layer and `null` in the tools. - **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 wireframe dashed; colours, widths and active/idle emphasis are unchanged. A manual calibration From 7ec84ac316a4976ebffa2891fe69d5fe3cdbeede Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 17 Oct 2026 11:40:30 -0700 Subject: [PATCH 3/3] Decode catalog numbers strictly and add the changelog fragment Route tleCatalogNumber() and noradIdFromSatnum() through one decoder that accepts only plain digits or a valid Alpha-5 field. parseInt and Number() read '2554A', '255 4', '-1234', '0x1F0', '1.5' and blank as numbers; they now return null/NaN. Digit satnums of any length still decode, so OMM satrecs numbered past 99999 keep working. Add a layer test that several Alpha-5 satellites survive ingestion dedupe, the changelog fragment for #767, and note in CURRENT-STATE that this is Alpha-5 TLE compatibility and malformed fields are rejected. --- changelog.d/767-alpha5-catalog-numbers.md | 11 ++++ docs/CURRENT-STATE.md | 2 +- src/layers/satellites/ingestion.test.mjs | 80 +++++++++++++++++++++++ src/sources/tle.js | 53 +++++++-------- src/tools/queries/space.test.mjs | 35 ++++++++-- 5 files changed, 147 insertions(+), 34 deletions(-) create mode 100644 changelog.d/767-alpha5-catalog-numbers.md create mode 100644 src/layers/satellites/ingestion.test.mjs diff --git a/changelog.d/767-alpha5-catalog-numbers.md b/changelog.d/767-alpha5-catalog-numbers.md new file mode 100644 index 000000000..8a96698a1 --- /dev/null +++ b/changelog.d/767-alpha5-catalog-numbers.md @@ -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). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 82d93b458..dad629b7e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4324,7 +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. - **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. -- **Catalog numbers above 99999** arrive 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. `src/sources/tle.js` decodes the field: `noradIdFromSatnum()` for the satellites layer, which keys and deduplicates its catalog by that number, and `tleCatalogNumber()` for the satellite tools. A five-digit field reads as before; a field that is neither stays `NaN` in the layer and `null` in the tools. +- **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 `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 diff --git a/src/layers/satellites/ingestion.test.mjs b/src/layers/satellites/ingestion.test.mjs new file mode 100644 index 000000000..313d999ee --- /dev/null +++ b/src/layers/satellites/ingestion.test.mjs @@ -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); +}); diff --git a/src/sources/tle.js b/src/sources/tle.js index f5fad48bd..f2b2ca3d7 100644 --- a/src/sources/tle.js +++ b/src/sources/tle.js @@ -21,50 +21,47 @@ export function parseTleText(text) { return result; } +// 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 an Alpha-5 field stands for, or null for any other field. + * The catalog number a catalog 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". + * 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 alpha5CatalogNumber(field) { +function decodeCatalogField(field) { + if (DIGITS.test(field)) return Number(field); + if (!ALPHA5.test(field)) return null; 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; + 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. + * 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. An Alpha-5 field decodes to its number; a - * five-digit field is unchanged; anything else stays NaN. + * 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) { - const field = String(satnum ?? '').trim(); - return alpha5CatalogNumber(field) ?? Number(field); + return decodeCatalogField(String(satnum ?? '').trim()) ?? Number.NaN; } -/** The NORAD catalog number from TLE line 1, or null. */ +/** The NORAD catalog number from TLE line 1 (columns 3–7), or null. */ export function tleCatalogNumber(line1) { 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; + return decodeCatalogField(field.replace(/^ +/, '')); } diff --git a/src/tools/queries/space.test.mjs b/src/tools/queries/space.test.mjs index 5ff267ce1..d0f2ffca8 100644 --- a/src/tools/queries/space.test.mjs +++ b/src/tools/queries/space.test.mjs @@ -72,14 +72,39 @@ test('Alpha-5 catalog numbers decode, skipping I and O', () => { } }); -test('every other catalog field reads as it did before', () => { +test('a digit 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); + 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 () => {