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 () => {