Later commits on this branch (0e9ab6f,ff744a0,8c3a860, and this round's fold, positional lookup and parallax fallback) changed counts that comments in build.ts, star-readouts.ts, star-catalog.ts and fetchExoplanets.ts still quoted as "measured" or "today". Each is now the value measured on the published catalogue after an ETL run from cache (decoded with decodeStarCatalog, or from the validator log where it prints them): - distances without a published error 439 -> 346, the Gliese ones 357 -> 264 (82 archive); - parallax distances ranged 1 109 -> 1 116; HYG stars at Gaia's distance 8 129 -> 8 105, and the Gaia-placed stars HYG describes 62 002 -> 61 713 at Gaia's distance, 62 097 in all; - HYG rows without a Gaia counterpart 11 554 -> 11 478: past 250 pc 8 307 (1 660 naked-eye), inside it 3 170 (1 204 brighter than V 8, 1 804 from 8 to 12, 162 fainter, 112 of them Gliese stars within 50 pc); the sentence also said those were every star past 250 pc, which the archive's hosts no longer let it be; - planets with a host 6 327 -> 6 328, the 26 left having neither a distance nor a parallax; - renamed hosts 575 -> 574; the composite table fills 100 of the 127 blank distances, not "the 100". No ceiling or floor changes; comments only. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
238 lines
12 KiB
TypeScript
238 lines
12 KiB
TypeScript
import { createHash } from 'node:crypto';
|
|
import { writeFileSync } from 'node:fs';
|
|
|
|
import { propagateProperMotion, raDegDecDistanceToXyz } from '../../src/app/shared/astro/coordinates';
|
|
import { ARCHIVE_EPOCH, archiveDistancePc, archiveStarId, buildStarNameIndex, CATALOGUE_EPOCH, resolveHostStarId } from '../../src/app/shared/astro/host-star-matching';
|
|
import { temperatureToColorIndex } from '../../src/app/shared/astro/spectral';
|
|
import { ExoplanetRecord } from '../../src/app/shared/models/exoplanet.model';
|
|
import { isDesignation } from '../../src/app/shared/models/star-catalog';
|
|
import { StarRecord } from '../../src/app/shared/models/star.model';
|
|
import { fetchStars, writeStarAssets } from './fetchStars';
|
|
import { parseCsvObjects, parseOptionalNumber } from './lib/csv';
|
|
import { fetchTextCached } from './lib/http';
|
|
import { dataPath, ensureDataDir } from './lib/paths';
|
|
|
|
const TAP_BASE_URL = 'https://exoplanetarchive.ipac.caltech.edu/TAP/sync';
|
|
const TAP_COLUMNS = [
|
|
'pl_name',
|
|
'hostname',
|
|
'ra',
|
|
'dec',
|
|
'sy_dist',
|
|
'sy_pmra',
|
|
'sy_pmdec',
|
|
'pl_orbsmax',
|
|
'pl_orbeccen',
|
|
'pl_orbincl',
|
|
'pl_orblper',
|
|
'pl_orbper',
|
|
'pl_rade',
|
|
'pl_bmasse',
|
|
'st_mass',
|
|
'disc_year'
|
|
].join(',');
|
|
// Ordered explicitly: without it the archive is free to return rows in any order, and a
|
|
// scheduled re-run of the ETL would then rewrite exoplanets.json — and commit a diff — when
|
|
// nothing was actually published. pl_name is unique among default_flag=1 rows, so the order
|
|
// is total and the output is a pure function of the archive's content.
|
|
const TAP_QUERY = `select+${TAP_COLUMNS}+from+ps+where+default_flag=1+order+by+pl_name&format=csv`;
|
|
const TAP_URL = `${TAP_BASE_URL}?query=${TAP_QUERY}`;
|
|
|
|
// The cache is keyed by the request it answers — endpoint included, since the cache records
|
|
// only that some response arrived: one cached before a column was added would otherwise keep
|
|
// serving rows without it, and a missing proper-motion cell reads as "does not move",
|
|
// silently wrong rather than visibly broken.
|
|
const CACHE_FILE = `exoplanet-archive-ps-${createHash('sha1').update(TAP_URL).digest('hex').slice(0, 8)}.csv`;
|
|
|
|
/**
|
|
* The host's columns from the Planetary Systems Composite table, for the cells a planet's
|
|
* default row leaves blank and for the columns that query does not ask for.
|
|
*
|
|
* The default rows are one reference each, which is what keeps a planet's orbit coherent: its
|
|
* period, semi-major axis, eccentricity and periastron come from one fit. A composite row takes
|
|
* each column from wherever it is best measured, so an orbit read from it could pair one paper's
|
|
* eccentricity with another's argument of periastron; none of its orbital columns are asked for.
|
|
* Its system columns equal the default rows' wherever both are given (6 225 distances, 6 352
|
|
* positions, none different). What it adds is 100 of the 127 distances the default rows leave blank,
|
|
* TRAPPIST-1's seven among them, 870 host masses, and the parallax, photometry and stellar
|
|
* parameters below — each of which may come from a different reference.
|
|
*/
|
|
const COMPOSITE_COLUMNS = [
|
|
'pl_name',
|
|
'ra',
|
|
'dec',
|
|
'sy_dist',
|
|
'sy_plx',
|
|
'sy_pmra',
|
|
'sy_pmdec',
|
|
'sy_bmag',
|
|
'sy_vmag',
|
|
'sy_gaiamag',
|
|
'st_spectype',
|
|
'st_teff',
|
|
'st_rad',
|
|
'st_mass',
|
|
'st_lum'
|
|
].join(',');
|
|
// pl_name is unique here too, one row per planet.
|
|
const COMPOSITE_URL = `${TAP_BASE_URL}?query=select+${COMPOSITE_COLUMNS}+from+pscomppars+order+by+pl_name&format=csv`;
|
|
const COMPOSITE_CACHE_FILE = `exoplanet-archive-pscomppars-${createHash('sha1').update(COMPOSITE_URL).digest('hex').slice(0, 8)}.csv`;
|
|
/**
|
|
* The same table's distance errors, for the stars placed from the archive. A query of its own,
|
|
* cached apart, so that asking for them did not refetch the columns above: the archive changes
|
|
* daily, and a new answer would have moved every figure the catalogue was checked against.
|
|
*/
|
|
const DISTANCE_ERRORS_URL = `${TAP_BASE_URL}?query=select+pl_name,sy_disterr1,sy_disterr2+from+pscomppars+order+by+pl_name&format=csv`;
|
|
const DISTANCE_ERRORS_CACHE_FILE = `exoplanet-archive-disterr-${createHash('sha1').update(DISTANCE_ERRORS_URL).digest('hex').slice(0, 8)}.csv`;
|
|
|
|
const ARCHIVE_SOURCE = 'exoplanet-archive';
|
|
/**
|
|
* The archive's positions are at Gaia DR2's epoch, J2015.5, not the catalogue's J2000 (see
|
|
* `ARCHIVE_EPOCH`): of the 746 matched hosts moving over 100 mas a year, 741 sit nearer their star
|
|
* once carried back (a median 0.11″ from it, against 3.47″ as published). The matcher tries both
|
|
* epochs; a star placed from the archive has to pick one.
|
|
*/
|
|
const ARCHIVE_TO_CATALOGUE_YEARS = CATALOGUE_EPOCH - ARCHIVE_EPOCH;
|
|
/** As in `fetchStars`: faint, for a host the archive gives neither a V nor a G magnitude. */
|
|
const UNKNOWN_MAGNITUDE = 15;
|
|
|
|
/**
|
|
* Downloads confirmed exoplanets from the NASA Exoplanet Archive (`Planetary Systems` TAP
|
|
* table), cross-references each host star to the star catalogue, adds a star from the archive's
|
|
* own figures for each host the catalogue lacks but the archive places, and writes
|
|
* `exoplanets.json` together with the star assets, which those additions change. Returns both.
|
|
*/
|
|
export async function fetchExoplanets(stars?: StarRecord[]): Promise<{ exoplanets: ExoplanetRecord[]; stars: StarRecord[] }> {
|
|
console.log('Fetching confirmed exoplanets from the NASA Exoplanet Archive...');
|
|
const knownStars = stars ?? (await fetchStars());
|
|
const nameIndex = buildStarNameIndex(knownStars);
|
|
const knownById = new Map(knownStars.map((star) => [star.id, star]));
|
|
|
|
const csv = await fetchTextCached(TAP_URL, CACHE_FILE);
|
|
const rows = parseCsvObjects(csv);
|
|
const composite = new Map(parseCsvObjects(await fetchTextCached(COMPOSITE_URL, COMPOSITE_CACHE_FILE)).map((row) => [row['pl_name'], row]));
|
|
const distanceErrors = new Map(parseCsvObjects(await fetchTextCached(DISTANCE_ERRORS_URL, DISTANCE_ERRORS_CACHE_FILE)).map((row) => [row['pl_name'], row]));
|
|
|
|
let matched = 0;
|
|
// Catalogue stars known only by their Gaia designation, which take the archive's host name —
|
|
// the only way "TRAPPIST-1" or "Teegarden's Star" can be found by search.
|
|
const renamed = new Map<number, string>();
|
|
const archiveStars = new Map<string, StarRecord>();
|
|
const archiveIds = new Set<number>();
|
|
const bands = { V: 0, G: 0, none: 0 };
|
|
const exoplanets: ExoplanetRecord[] = rows.map((row, index) => {
|
|
const compositeRow = composite.get(row['pl_name']);
|
|
// `parseOptionalNumber`, not `Number`: a blank cell would otherwise become 0, which is a
|
|
// finite, plausible-looking coordinate rather than the "not measured" it actually means.
|
|
const host = (column: string) => parseOptionalNumber(row[column] || compositeRow?.[column]);
|
|
const raDeg = host('ra') ?? Number.NaN;
|
|
const decDeg = host('dec') ?? Number.NaN;
|
|
const distancePc = archiveDistancePc(host('sy_dist'), host('sy_plx'));
|
|
const pmRaMasPerYear = host('sy_pmra');
|
|
const pmDecMasPerYear = host('sy_pmdec');
|
|
|
|
let hostStarId = resolveHostStarId(
|
|
{ hostname: row['hostname'], raDeg, decDeg, distancePc, pmRaMasPerYear, pmDecMasPerYear, parallaxMas: host('sy_plx') },
|
|
knownStars,
|
|
nameIndex
|
|
);
|
|
if (hostStarId !== null) {
|
|
matched++;
|
|
const star = knownById.get(hostStarId);
|
|
if (star?.source === 'gaia' && isDesignation(star) && !renamed.has(hostStarId)) {
|
|
renamed.set(hostStarId, row['hostname']);
|
|
}
|
|
} else if ([raDeg, decDeg, distancePc].every(Number.isFinite) && distancePc > 0) {
|
|
let archiveStar = archiveStars.get(row['hostname']);
|
|
if (!archiveStar) {
|
|
// Carried back from the archive's epoch like any Gaia row, and placed at `sy_dist`.
|
|
const j2000 = propagateProperMotion(raDeg, decDeg, pmRaMasPerYear ?? 0, pmDecMasPerYear ?? 0, ARCHIVE_TO_CATALOGUE_YEARS);
|
|
// V where the archive has it, as HYG's stars are; else Gaia's G, the band the catalogue's
|
|
// Gaia stars are already in.
|
|
const v = host('sy_vmag');
|
|
const g = host('sy_gaiamag');
|
|
const b = host('sy_bmag');
|
|
const temperatureK = host('st_teff');
|
|
const band = v !== undefined ? 'V' : g !== undefined ? 'G' : undefined;
|
|
bands[band ?? 'none']++;
|
|
// B-V where the archive has both magnitudes, else the effective temperature's; with
|
|
// neither, null leaves the colour to the spectral type, as for any other star.
|
|
const colorIndex = b !== undefined && v !== undefined ? b - v : temperatureK !== undefined ? temperatureToColorIndex(temperatureK) : null;
|
|
// The archive gives the distance's error as two one-sided ones; their mean, relative.
|
|
const errors = distanceErrors.get(row['pl_name']);
|
|
const [above, below] = [parseOptionalNumber(errors?.['sy_disterr1']), parseOptionalNumber(errors?.['sy_disterr2'])];
|
|
archiveStar = {
|
|
id: archiveStarId(row['hostname'], archiveIds),
|
|
name: row['hostname'],
|
|
...raDegDecDistanceToXyz(j2000.raDeg, j2000.decDeg, distancePc),
|
|
magnitude: v ?? g ?? UNKNOWN_MAGNITUDE,
|
|
...(band === undefined ? {} : { magnitudeBand: band }),
|
|
spectralType: compositeRow?.['st_spectype'] || 'Unknown',
|
|
colorIndex,
|
|
...(colorIndex === null ? {} : { colorSystem: 'B-V' as const }),
|
|
...(colorIndex !== null && (b === undefined || v === undefined) ? { colorFromTemperature: true } : {}),
|
|
...(above === undefined || below === undefined ? {} : { distanceError: (Math.abs(above) + Math.abs(below)) / 2 / distancePc }),
|
|
source: ARCHIVE_SOURCE
|
|
};
|
|
archiveStars.set(row['hostname'], archiveStar);
|
|
archiveIds.add(archiveStar.id);
|
|
}
|
|
hostStarId = archiveStar.id;
|
|
}
|
|
|
|
return {
|
|
id: row['pl_name'] || `exoplanet-${index}`,
|
|
hostStarId,
|
|
hostStarName: row['hostname'],
|
|
name: row['pl_name'],
|
|
radiusEarth: parseOptionalNumber(row['pl_rade']),
|
|
massEarth: parseOptionalNumber(row['pl_bmasse']),
|
|
discoveryYear: parseOptionalNumber(row['disc_year']),
|
|
// The period was already being downloaded and thrown away. With the semi-major axis it
|
|
// determines the host's gravitational parameter, so keeping it is the difference between
|
|
// propagating a planet at its real rate and pretending every host is the Sun.
|
|
periodDays: parseOptionalNumber(row['pl_orbper']),
|
|
hostStarMassSolar: host('st_mass'),
|
|
hostStarRadiusSolar: host('st_rad'),
|
|
hostStarTemperatureK: host('st_teff'),
|
|
// Published as log10(L/L☉).
|
|
hostStarLuminositySolar: ((logLuminosity) => (logLuminosity === undefined ? undefined : 10 ** logLuminosity))(host('st_lum')),
|
|
// Kept so the cross-reference can be redone without the archive; see the record's own
|
|
// documentation. Undefined rather than NaN, which JSON cannot represent.
|
|
hostRaDeg: host('ra'),
|
|
hostDecDeg: host('dec'),
|
|
hostDistancePc: host('sy_dist'),
|
|
hostPmRaMasPerYear: pmRaMasPerYear,
|
|
hostPmDecMasPerYear: pmDecMasPerYear,
|
|
orbit: {
|
|
semiMajorAxisAu: parseOptionalNumber(row['pl_orbsmax']),
|
|
eccentricity: parseOptionalNumber(row['pl_orbeccen']),
|
|
inclinationDeg: parseOptionalNumber(row['pl_orbincl']),
|
|
argumentOfPeriapsisDeg: parseOptionalNumber(row['pl_orblper'])
|
|
}
|
|
};
|
|
});
|
|
|
|
// Appended after the catalogue, whose ids all sit below ARCHIVE_ID_BASE, and sorted, so the list
|
|
// stays in the id order `fetchStars` sorted it into.
|
|
const added = [...archiveStars.values()].sort((a, b) => a.id - b.id);
|
|
const allStars = [...knownStars.map((star) => (renamed.has(star.id) ? { ...star, name: renamed.get(star.id)! } : star)), ...added];
|
|
writeStarAssets(allStars);
|
|
|
|
ensureDataDir();
|
|
writeFileSync(dataPath('exoplanets.json'), JSON.stringify(exoplanets));
|
|
console.log(
|
|
` wrote ${exoplanets.length} exoplanets: ${matched} on a catalogue star (${renamed.size} Gaia designations named after their host), ` +
|
|
`${exoplanets.filter((exoplanet) => exoplanet.hostStarId !== null).length - matched} on ${added.length} stars added from the archive ` +
|
|
`(${added.filter((star) => Math.hypot(star.x, star.y, star.z) <= 250).length} within 250 pc; magnitude in V for ${bands.V}, in G for ${bands.G}, none for ${bands.none}).`
|
|
);
|
|
return { exoplanets, stars: allStars };
|
|
}
|
|
|
|
if (require.main === module) {
|
|
fetchExoplanets().catch((error) => {
|
|
console.error(error);
|
|
process.exitCode = 1;
|
|
});
|
|
}
|