diff --git a/src/layers/satellites/classes.js b/src/layers/satellites/classes.js index 07822089e..deaf1b3da 100644 --- a/src/layers/satellites/classes.js +++ b/src/layers/satellites/classes.js @@ -147,10 +147,14 @@ function launchYearOf(launchDate) { /** * The class a satellite belongs to. * @param {{noradId?: number, name?: string, group?: string|null, + * groups?: string[]|null, * ucs?: {purpose?: string|null}|null, * satcat?: {objectType?: string, opsStatus?: string, * launchDate?: string|null}|null, - * launchYear?: number|null, nowYear?: number}} subject `launchYear` is + * launchYear?: number|null, nowYear?: number}} subject `groups` is every + * CelesTrak group the TLE came in (`group` is the one it is shown under): + * the first of them that names a class gives the last-resort class, so a + * satellite in both `visual` and `resource` is still imaging. `launchYear` is * the TLE designator's; SATCAT's launch date wins where it has one (an * object deployed from the ISS carries the station's 1998 designator). * @returns {'stations'|'imaging'|'telecom'|'starlink'|'probes'|'debris'} @@ -161,6 +165,7 @@ export function classifySatellite({ group = null, ucs = null, satcat = null, + groups = null, launchYear = null, nowYear = new Date().getUTCFullYear(), } = {}) { @@ -199,7 +204,10 @@ export function classifySatellite({ ) return 'debris'; - return GROUP_CLASS[group] || 'probes'; + const memberships = groups?.length ? groups : [group]; + for (const membership of memberships) + if (GROUP_CLASS[membership]) return GROUP_CLASS[membership]; + return 'probes'; } /** Whether a class may be ridden in the Cockpit's orbital view. */ diff --git a/src/layers/satellites/ingestion.js b/src/layers/satellites/ingestion.js index e1d888081..f65b476ee 100644 --- a/src/layers/satellites/ingestion.js +++ b/src/layers/satellites/ingestion.js @@ -72,8 +72,16 @@ export function createIngestion({ continue; } if (group.stale && satcatState === 'current') satcatState = 'stale'; - for (const [noradId, record] of group.records) - layerState._satcat.set(noradId, record); + for (const [noradId, record] of group.records) { + // A group served from the proxy's last copy never overrules a + // current read of the same object, from this merge or an earlier one. + const held = layerState._satcat.get(noradId); + if (group.stale && held && !held.stale) continue; + layerState._satcat.set( + noradId, + group.stale ? { ...record, stale: true } : record, + ); + } } if (!groups.length) return; const changed = layerState._satcatState !== satcatState; @@ -100,6 +108,7 @@ export function createIngestion({ noradId, name: sat.name, group: sat.group, + groups: sat.groups, ucs: sat.ucs, satcat, launchYear: sat.launchYear, @@ -259,6 +268,18 @@ export function createIngestion({ allEntries.push({ ...e, group: r.tag, stale: r.stale }); } + // Every group each TLE came in, keyed by its catalog-number column: + // the shown tag is the first, but classification reads them all. + const groupsByNumber = new Map(); + for (const entry of allEntries) { + const number = String(entry.line1 || '') + .slice(2, 7) + .trim(); + const groups = groupsByNumber.get(number); + if (!groups) groupsByNumber.set(number, [entry.group]); + else if (!groups.includes(entry.group)) groups.push(entry.group); + } + // Deduplicate by NORAD ID — first (most specific) group tag wins const seen = new Set(); let coreStale = false; @@ -275,10 +296,16 @@ export function createIngestion({ const launchYear = tleLineLaunchYear(entry.line1); const ucs = layerState._ucs?.get(noradId) || null; const satcat = layerState._satcat.get(noradId) || null; + const groups = groupsByNumber.get( + String(entry.line1 || '') + .slice(2, 7) + .trim(), + ) || [entry.group]; layerState._catalog.set(noradId, { name: entry.name, satrec, group: entry.group, + groups, // satellite.js does not keep the designator the year match reads. launchYear, line1: entry.line1, @@ -287,6 +314,7 @@ export function createIngestion({ noradId, name: entry.name, group: entry.group, + groups, ucs, satcat, launchYear, diff --git a/src/layers/satellites/ingestion.test.mjs b/src/layers/satellites/ingestion.test.mjs index 494ff8e09..c25b90158 100644 --- a/src/layers/satellites/ingestion.test.mjs +++ b/src/layers/satellites/ingestion.test.mjs @@ -61,8 +61,10 @@ function ingestionFor(catalogText, { source: sourceExtras = {} } = {}) { catalog: {}, }; const source = { - async readGroup() { - return { ok: true, text: catalogText }; + async readGroup(path) { + const text = + typeof catalogText === 'function' ? catalogText(path) : catalogText; + return { ok: true, text }; }, ...sourceExtras, }; @@ -141,9 +143,10 @@ test('a hanging SATCAT read neither holds the catalog nor is lost when it lands' const satcatAnswer = new Promise((resolve) => { answer = resolve; }); - const { state, update } = ingestionFor(tleSet('OLD STAGE', '40115'), { - source: { readSatcat: () => satcatAnswer }, - }); + const { state, update } = ingestionFor( + (path) => (path === 'stations' ? tleSet('OLD STAGE', '40115') : ''), + { source: { readSatcat: () => satcatAnswer } }, + ); state._enabled = true; const settle = async () => { for (let turn = 0; turn < 20; turn += 1) @@ -170,6 +173,45 @@ test('a hanging SATCAT read neither holds the catalog nor is lost when it lands' assert.equal(state._satcatState, 'stale', 'its staleness is kept'); }); +test('a satellite in visual and resource is imaging, whichever group shows it', async (t) => { + t.mock.method(console, 'log', () => {}); + t.mock.method(console, 'warn', () => {}); + // A name no pattern knows, no UCS record and no SATCAT: only its groups. + const { state, update } = ingestionFor((path) => + path === 'visual' || path === 'resource' ? tleSet('ZZ TESTSAT', '50001') : '', + ); + await update(); + const sat = state._catalog.get(50001); + assert.equal(sat.group, 'visual', 'shown under the first group'); + assert.deepEqual(sat.groups, ['visual', 'resource']); + assert.equal(sat.satelliteClass, 'imaging'); +}); + +test('a stale SATCAT copy never overrules a current read of the same object', async (t) => { + t.mock.method(console, 'log', () => {}); + t.mock.method(console, 'warn', () => {}); + const header = + 'OBJECT_NAME,OBJECT_ID,NORAD_CAT_ID,OBJECT_TYPE,OPS_STATUS_CODE,OWNER\n'; + const { state, update } = ingestionFor(tleSet('ZZ TESTSAT', '50002'), { + source: { + // visual is current and says it died; resource, a cached copy, still + // says it works. + readSatcat: async (path) => + path === 'visual' + ? { ok: true, text: `${header}ZZ TESTSAT,2015-001A,50002,PAY,-,US` } + : path === 'resource' + ? { + ok: true, + stale: true, + text: `${header}ZZ TESTSAT,2015-001A,50002,PAY,+,US`, + } + : { ok: true, text: header }, + }, + }); + await update(); + assert.equal(state._catalog.get(50002).satelliteClass, 'debris'); +}); + test('a failed SATCAT read is told on the layer row, not only the console', async (t) => { t.mock.method(console, 'log', () => {}); t.mock.method(console, 'warn', () => {});