fix(satellites): classes read every group a TLE came in, and current SATCAT

Deduplication kept only the first group's tag, so a recent imaging
satellite in both visual and resource, with no UCS record or known name,
fell to Probes. The catalog entry now keeps every group, and the
last-resort class is the first of them that names one.

SATCAT groups merged in group order, so a stale cached copy read later
overwrote a current record of the same object: a satellite SATCAT now
calls dead stayed in Imaging. A stale copy no longer overrules a current
read, from the same merge or an earlier one.
This commit is contained in:
Sameh Khamis committed 2026-11-08 15:16:01 -08:00
1 parent c84cfcadeb
commit 8bd16b0be9
3 files changed
+87 -9

No files matched your search

+10 -2
View File
@@ -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. */
+30 -2
View File
@@ -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,
+47 -5
View File
@@ -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', () => {});