The readout named Hipparcos, Yale and Gliese while 378 775 of the 455 608 stars are described by Gaia DR3, printed one "Magnitude" for G and V alike, and gave every distance to the parsec. The band cannot be read off the star's source, which records whose position it has: 62 002 stars Gaia places keep HYG's V and B-V, and the 575 hosts renamed after their planets are Gaia's, in G. stars-meta.bin gains two byte columns, 14 to 16 bytes a star (6 378 512 to 7 289 728 bytes; gzip -9 2 804 049 to 3 110 991). One holds the magnitude's band (V 76 555 stars, G 378 744, none 309, whose magnitude is a stand-in), which colour the colour index is (B-V 75 203, BP-RP 376 703), and whether the distance is Gaia's parallax. The other holds the distance's relative error as its square root in 255ths: a step is 0.08 % of distance at 1 %, 0.35 % at 20 %, and 100 % is the top. encode, decode, BYTES_PER_STAR_META and the build.ts round trip cover both; no workflow reads the format. Where the errors come from: - Gaia rows keep the parallax_error their query already fetched: median 0.3 %, 90th percentile 1.2 %, at most 20 %, the query's own cut. - A HYG star at Gaia's distance takes the cross-match's parallax_over_error, and a star merged into a Gaia entry keeps that entry's error with its position. - The 3 067 Hipparcos stars that keep their Hipparcos distance, Rigel, Deneb and Alnilam among them, take e_plx from van Leeuwen's 2007 reduction: a new cached query of public.hipparcos_newreduction on the ESA archive, whose 117 955 rows HYG's distances invert. - The archive's stars take sy_disterr1/2 from pscomppars, in a query and cache file of their own so the composite rows already cached were not refetched. - 439 distances have no published error: 357 Gliese rows and 82 archive hosts. Of the errors, 392 786 are 1 % or less and are not printed; 61 156 print as "117 ± 12 pc" to the distance's own digits; 1 196 between 20 and 100 %, and 30 past it, print as the range the parallax gives, since a symmetric error in parallax is a lopsided one in distance. The star card (measured on the dev server) now reads, for example: - Rigel: 265 ± 23 pc, V 0.18, B-V -0.03, source HYG. - Deneb: 433 ± 60 pc. - Alnilam: "476 pc to 833 pc" (Hipparcos 1.65 ± 0.45 mas). - Gaia DR3 5612323414549657984: 111 ± 2 pc, G 4.63, BP-RP -0.15, source Gaia DR3. - Proxima Centauri: 1.30 pc, V 11.01, source "HYG, Gaia DR3 distance". - TRAPPIST-1: G 15.62, BP-RP 4.90, source Gaia DR3. - Kepler-186: V 15.14, source NASA Exoplanet Archive. The neighbourhood's subtitle reads "Gaia DR3 378,775 · HYG 73,556 · NASA Exoplanet Archive 3,277", counted by the catalogue describing each star. build.ts validateStars now fails a catalogue with more than 1 000 stars without a band (309 today) or without a distance error (439). Dropping G from the Gaia rows gave 379 040 without a band, and dropping their parallax_error gave 441 216 without an error; both runs failed. Decoding the catalogue in Node took a median 29 ms before and 24 ms after (nine runs each, within noise). In the app, five cold boots gave a 654-786 ms long task after the data landed and the HUD at 1.83-2.07 s. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
240 lines
12 KiB
TypeScript
240 lines
12 KiB
TypeScript
import { createHash } from 'node:crypto';
|
|
import { writeFileSync } from 'node:fs';
|
|
|
|
import { propagateProperMotion, raDegDecDistanceToXyz } from '../../src/app/shared/astro/coordinates';
|
|
import { buildStarNameIndex, 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 the 100 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`;
|
|
|
|
/**
|
|
* Where the ids of the stars only the archive places begin: past Gaia's two ranges, and under
|
|
* the 2^30 `validateStars` holds every id to.
|
|
*/
|
|
const ARCHIVE_ID_BASE = 1_070_000_000;
|
|
const ARCHIVE_SOURCE = 'exoplanet-archive';
|
|
/**
|
|
* The archive's positions are at Gaia's epoch, J2016, not the catalogue's J2000: of the 746
|
|
* matched hosts moving over 100 mas a year, 741 sit nearer their star once carried back sixteen
|
|
* years (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 = 2000 - 2016;
|
|
/** 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 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 = host('sy_dist') ?? Number.NaN;
|
|
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: ARCHIVE_ID_BASE + archiveStars.size,
|
|
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 }),
|
|
...(above === undefined || below === undefined ? {} : { distanceError: (Math.abs(above) + Math.abs(below)) / 2 / distancePc }),
|
|
source: ARCHIVE_SOURCE
|
|
};
|
|
archiveStars.set(row['hostname'], archiveStar);
|
|
}
|
|
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, so the list stays in
|
|
// the id order `fetchStars` sorted it into.
|
|
const added = [...archiveStars.values()];
|
|
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;
|
|
});
|
|
}
|