Compare commits
73
Commits
@@ -41,12 +41,17 @@ jobs:
|
||||
|
||||
# Gaia DR3 is a frozen release: the same query returns the same bytes (a live re-fetch has
|
||||
# reproduced stars.bin exactly), so its responses are carried from one run to the next
|
||||
# rather than re-downloaded every week from an archive that times out under load. The key
|
||||
# follows gaia.ts, where the queries are written, so a changed query is fetched afresh.
|
||||
# rather than re-downloaded every week from an archive that times out under load. So is
|
||||
# van Leeuwen's 2007 Hipparcos reduction, as frozen and on the same archive, whose parallax
|
||||
# errors fetchStars requires: left out, it was fetched live every week, and an ESA outage would
|
||||
# have failed the refresh with every Gaia answer cached. The key follows gaia.ts, where the
|
||||
# queries are written, so a changed query is fetched afresh.
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: tools/etl/.cache/gaia-dr3-*.csv
|
||||
key: gaia-dr3-${{ hashFiles('tools/etl/sources/gaia.ts') }}
|
||||
path: |
|
||||
tools/etl/.cache/gaia-dr3-*.csv
|
||||
tools/etl/.cache/hipparcos-errors-*.csv
|
||||
key: gaia-dr3-hipparcos-${{ hashFiles('tools/etl/sources/gaia.ts') }}
|
||||
|
||||
# Every other source is fetched live on this fresh runner. A failed fetch fails the run by
|
||||
# design — no refresh is better than a partial one. That includes Gaia on a cold cache, by
|
||||
|
||||
@@ -72,15 +72,19 @@ instead of having to be inferred from a shape in space. The camera frames that g
|
||||
the orbits, from the field of view it actually has, so the outermost ring sits inside the frame
|
||||
with room around it at any system scale and any window shape.
|
||||
|
||||
The star at the centre is sized against the system's *innermost* orbit, so it can never swallow
|
||||
its closest planet, while the camera is placed to frame the *outermost* ring — and in the solar
|
||||
system those differ by a factor of a hundred. At the distance that fits Pluto in view, a disc
|
||||
that stays clear of Mercury is about a pixel across, and no radius satisfies both. So the disc
|
||||
stays honest to the orbits and the star's halo carries its visibility, floored against the framed
|
||||
radius: light is not a surface, and a glow reaching past the innermost orbit says the star is
|
||||
bright rather than that it is large. That floor is bounded from both sides — large enough that
|
||||
the star reads at a glance, small enough that Venus's and Earth's orbits stay legible as rings
|
||||
around it. Mercury's, three pixels wide at that range, does not survive either way.
|
||||
The star at the centre is drawn at its own radius, to the same scale as its orbits: the
|
||||
archive's measured radius for a planet host, and otherwise one derived from its luminosity and
|
||||
temperature (Stefan-Boltzmann), which the card marks with what the temperature came from: "from
|
||||
colour and brightness", "from its type and brightness" for a giant or a star whose colour the
|
||||
dwarf table does not read, or "from its temperature and brightness" for an archive host whose
|
||||
colour was read off its temperature. Its surface is a limb-darkened disc in the colour of a
|
||||
blackbody at its temperature, and its planets are lit
|
||||
in that colour, relative to the Sun's, so the solar system's photographs stay as they were
|
||||
taken. A star nothing gives a size or a temperature for is a grey point. Like every marker, the
|
||||
disc is never drawn smaller than three pixels, so a red dwarf framed with its outermost orbit
|
||||
still shows; there is no halo. The camera comes no closer to its centre than 0.05 AU or three of
|
||||
its radii, whichever is further, and a giant is framed far enough back that its disc stays
|
||||
inside the ring its neighbours' names are drawn on.
|
||||
|
||||

|
||||
|
||||
@@ -212,16 +216,18 @@ re-runs are cheap and offline-friendly; set `ETL_FORCE_REFRESH=1` to bypass the
|
||||
|
||||
| Script | Source | Output |
|
||||
| --- | --- | --- |
|
||||
| `fetchStars.ts` | HYG database, plus any other positional catalogue wired in (see below) | `stars.bin`, `stars-meta.bin`, `stars-index.json` |
|
||||
| `fetchStars.ts` | HYG database, plus any other positional catalogue wired in (see below) | the catalogue stars, handed to `fetchExoplanets.ts` |
|
||||
| `fetchSolarSystem.ts` | JPL SSD mean elements (Standish's planets, the satellite table), the Small-Body Database, NAIF's PCK, JPL Horizons | `bodies.json` |
|
||||
| `fetchExoplanets.ts` | NASA Exoplanet Archive (TAP) | `exoplanets.json` |
|
||||
| `fetchExoplanets.ts` | NASA Exoplanet Archive (TAP), and the stars above with the hosts it adds | `exoplanets.json`, `stars.bin`, `stars-meta.bin`, `stars-index.json` |
|
||||
| `fetchDeepSky.ts` | OpenNGC | `deepsky.json` |
|
||||
|
||||
The star catalogue ships as two binary column stores plus a small JSON file, not as an array of
|
||||
objects. At 68 388 stars the old encoding — one JSON object per star, its eight key names
|
||||
repeated each time — would have been about 17 MB to download and parse before the first frame.
|
||||
Splitting it puts the numbers in `stars.bin` (positions, handed to the GPU verbatim) and
|
||||
`stars-meta.bin` (id, magnitude, colour index, spectral-type index), and leaves `stars-index.json`
|
||||
`stars-meta.bin` (id, magnitude, colour index, spectral-type index, the distance's relative
|
||||
error in two bytes, and the band and colour system those were measured in or whether the colour
|
||||
was read off a temperature), and leaves `stars-index.json`
|
||||
holding only the strings, with the ~2 600 distinct spectral classifications collapsed into a
|
||||
dictionary. The result is 2.6 MB for 7.8× the stars. `star-catalog.ts` defines the layout once
|
||||
and both the ETL and the app use it, so the writer and the reader cannot drift apart.
|
||||
@@ -294,8 +300,8 @@ So deep-sky records store a **unit direction** on the celestial sphere rather th
|
||||
the line of sight is always known precisely, and the objects are drawn as a fixed-radius
|
||||
backdrop shell where true distance would be unusable anyway. `distancePc` is optional metadata,
|
||||
derived from parallax for galactic objects or the Hubble law for genuinely distant galaxies,
|
||||
and left `null` — with its `distanceMethod` — whenever neither is trustworthy. Roughly 330 of
|
||||
the 463 cataloged objects get a distance; the rest honestly report none.
|
||||
and left `null` — with its `distanceMethod` — whenever neither is trustworthy. 340 of
|
||||
the 488 cataloged objects get a distance; the rest honestly report none.
|
||||
|
||||
## Layout
|
||||
|
||||
|
||||
@@ -28,8 +28,8 @@ export interface BodyDetailViewModel {
|
||||
/**
|
||||
* What this world is inferred to look like, and the quantities that inference rests on. Always
|
||||
* present — every body has measurements enough to place it somewhere — but its individual
|
||||
* fields are nullable, since a body whose host star is not in the catalogue has no derived
|
||||
* temperature.
|
||||
* fields are nullable, since a body whose host star's luminosity or whose orbit's size is not
|
||||
* known has no derived temperature.
|
||||
*/
|
||||
appearance: PlanetAppearance;
|
||||
/** True when a real photograph is being shown rather than the derived surface. */
|
||||
|
||||
@@ -79,6 +79,12 @@ export function bodyReadouts(body: BodyDetailViewModel): BodyReadouts {
|
||||
* The derived surface is a reasoned illustration, and a panel of real measurements sitting next
|
||||
* to it is exactly the context in which it could be mistaken for another one.
|
||||
*
|
||||
* With no temperature, it says what is missing without claiming which: the host's luminosity, or
|
||||
* the orbit's size. It used to say the host was not in the catalogue, which is so for 27 of the
|
||||
* 2 714 planets it was printed on. Of the rest, 2 420 have no semi-major axis, and since 869635b
|
||||
* 267 have a host no survey measured the brightness of — OGLE-2005-BLG-390L b, read inside its
|
||||
* own host's system.
|
||||
*
|
||||
* A moon or dwarf planet drawn this way has been imaged — Voyager 2 photographed Uranus's five
|
||||
* large moons, Proteus and Nereid, Cassini Hyperion, and Hubble sees Eris, Haumea and Makemake as
|
||||
* points — but has no global map this app can use. So have the hundred or so exoplanets the
|
||||
@@ -98,6 +104,6 @@ function provenanceFor(body: BodyDetailViewModel): string {
|
||||
? 'it has been imaged only as a point of light beside its star, and no map of it is used here'
|
||||
: 'no image of this world exists';
|
||||
return body.appearance.equilibriumTemperatureK === null
|
||||
? `Surface illustrated from this body’s measured size and mass. Its host star is not in the catalogue, so no temperature could be derived. Not an observation — ${why}.`
|
||||
? `Surface illustrated from this body’s measured size and mass. No temperature could be derived: its star’s luminosity or its orbit’s size is not known. Not an observation — ${why}.`
|
||||
: `Surface illustrated from the measurements above — size, density and the temperature derived from its star’s output and its orbit. Not an observation — ${why}.`;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { BodyRecord, OrbitalElements } from '../../shared/models/body.model';
|
||||
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
|
||||
import { StarRecord, SUN_STAR_ID } from '../../shared/models/star.model';
|
||||
import { bodyReadouts } from './body-readouts';
|
||||
import { buildBodyViewModel } from './body-view-model';
|
||||
import { buildBodyViewModel, luminosityOf, publishedTemperaturesK, starSurfaceOf } from './body-view-model';
|
||||
|
||||
const orbit = (overrides: Partial<OrbitalElements> = {}): OrbitalElements => ({
|
||||
semiMajorAxisAu: 1,
|
||||
@@ -158,3 +158,77 @@ describe('buildBodyViewModel', () => {
|
||||
expect(buildBodyViewModel('earth', catalogues)?.hostStarId).toBe(SUN_STAR_ID);
|
||||
});
|
||||
});
|
||||
|
||||
describe('luminosityOf', () => {
|
||||
// KMT-2016-BLG-1107L as the ETL adds it from the archive: no V, no G, so the stand-in 15.
|
||||
const lens: StarRecord = { id: 1070000536, name: 'KMT-2016-BLG-1107L', x: 6651, y: 0, z: 0, magnitude: 15, spectralType: 'Unknown', colorIndex: null, source: 'exoplanet-archive' };
|
||||
const lensB: ExoplanetRecord = { id: 'lens-b', hostStarId: lens.id, hostStarName: lens.name, name: 'KMT-2016-BLG-1107L b', orbit: { semiMajorAxisAu: 0.342 } };
|
||||
|
||||
it('has none from a magnitude no survey measured, and gives its planets no temperature from it', () => {
|
||||
expect(luminosityOf(lens)).toBeNull();
|
||||
expect(buildBodyViewModel('lens-b', { bodies: [], exoplanets: [lensB], stars: [lens] })?.appearance.equilibriumTemperatureK).toBeNull();
|
||||
// The same figure measured in V is a star, 37 L☉ at that distance.
|
||||
expect(luminosityOf({ ...lens, magnitudeBand: 'V' })).toBeCloseTo(36.8, 0);
|
||||
});
|
||||
|
||||
it('is 1 for the Sun, whatever its magnitude is filed under', () => {
|
||||
expect(luminosityOf(sun)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('starSurfaceOf', () => {
|
||||
// Proxima Centauri as HYG describes it, and one of its planets' archive rows.
|
||||
const proxima: StarRecord = { id: 70666, name: 'Proxima Centauri', x: 1.2959, y: 0, z: 0, magnitude: 11.01, magnitudeBand: 'V', spectralType: 'M5Ve', colorIndex: 1.807, colorSystem: 'B-V' };
|
||||
const proximaB: ExoplanetRecord = { id: 'proxima-cen-b', hostStarId: 70666, hostStarName: 'Proxima Cen', name: 'Proxima Cen b', orbit: { semiMajorAxisAu: 0.0485 } };
|
||||
|
||||
it("is the Sun's own for the Sun, and not derived", () => {
|
||||
expect(starSurfaceOf(sun, [])).toEqual({ radiusSolar: 1, radiusDerived: false, temperatureK: 5772, luminositySolar: 1, luminosityDerived: false });
|
||||
});
|
||||
|
||||
it("takes a host's radius, temperature and luminosity from the archive", () => {
|
||||
const surface = starSurfaceOf(proxima, [{ ...proximaB, hostStarRadiusSolar: 0.141, hostStarTemperatureK: 2900, hostStarLuminositySolar: 0.00151 }]);
|
||||
expect(surface).toEqual({ radiusSolar: 0.141, radiusDerived: false, temperatureK: 2900, luminositySolar: 0.00151, luminosityDerived: false });
|
||||
});
|
||||
|
||||
it('warms a planet by the luminosity the archive gives its host, on its own page as in its system', () => {
|
||||
// 8.9×10⁻⁴ L☉ from Proxima's V and B−V, which put b at 200 K; the archive's 1.51×10⁻³ at 228 K.
|
||||
const b = { ...proximaB, hostStarLuminositySolar: 0.00151 };
|
||||
const catalogues = { bodies: [], exoplanets: [b, { ...proximaB, id: 'proxima-cen-d', name: 'Proxima Cen d', orbit: { semiMajorAxisAu: 0.02881 } }], stars: [proxima] };
|
||||
expect(buildBodyViewModel('proxima-cen-b', catalogues)?.appearance.equilibriumTemperatureK).toBeCloseTo(228, 0);
|
||||
// d's own row gives none; its host's luminosity is still the archive's, from b's.
|
||||
expect(buildBodyViewModel('proxima-cen-d', catalogues)?.appearance.equilibriumTemperatureK).toBeCloseTo(296, 0);
|
||||
});
|
||||
|
||||
it("gives the star field the temperature the disc is drawn at, where a host's planets give it different ones", () => {
|
||||
// 193 hosts do: host 1070876212's three rows give 4 094, 4 094 and 3 640 K. The field is tinted
|
||||
// from one map of every host and the disc from the host's own planets, so both must take the same row.
|
||||
const rows = [
|
||||
{ ...proximaB, id: 'd', hostStarTemperatureK: undefined },
|
||||
{ ...proximaB, id: 'b', hostStarTemperatureK: 4094 },
|
||||
{ ...proximaB, id: 'c', hostStarTemperatureK: 3640 },
|
||||
];
|
||||
const other = { ...proximaB, id: 'other', hostStarId: 1, hostStarTemperatureK: 5000 };
|
||||
expect(starSurfaceOf(proxima, rows).temperatureK).toBe(4094);
|
||||
expect(publishedTemperaturesK([...rows, other]).get(proxima.id)).toBe(starSurfaceOf(proxima, rows).temperatureK);
|
||||
});
|
||||
|
||||
it('derives both otherwise, and says the radius is derived', () => {
|
||||
const surface = starSurfaceOf(proxima, [proximaB]);
|
||||
expect(surface.radiusDerived).toBe(true);
|
||||
expect(surface.luminosityDerived).toBe(true);
|
||||
expect(surface.luminositySolar).toBeCloseTo(0.00088, 5);
|
||||
// Its colour reads as an M5 dwarf: 3 068 K and 0.105 R☉, against 2 900 K and 0.154 R☉
|
||||
// measured (Kervella et al. 2017). B−V barely changes along the late M dwarfs.
|
||||
expect(surface.temperatureK).toBeCloseTo(3068, -1);
|
||||
expect(surface.radiusSolar).toBeCloseTo(0.105, 2);
|
||||
});
|
||||
|
||||
it('has no radius for a star with neither a colour nor a type', () => {
|
||||
expect(starSurfaceOf({ ...proxima, colorIndex: null, spectralType: 'Unknown' }, []).radiusSolar).toBeNull();
|
||||
});
|
||||
|
||||
it('has none from a magnitude no survey measured', () => {
|
||||
// No band: the magnitude is the ETL's stand-in, and the luminosity from it means nothing.
|
||||
expect(starSurfaceOf({ ...proxima, magnitudeBand: undefined }, []).radiusSolar).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { appearanceForBody, appearanceForExoplanet } from '../../shared/astro/body-appearance';
|
||||
import { EARTH_RADIUS_KM } from '../../shared/astro/planet-appearance';
|
||||
import { luminositySolar } from '../../shared/astro/stellar';
|
||||
import { effectiveTemperatureK, luminositySolar, radiusFromLuminositySolar, SOLAR_EFFECTIVE_TEMPERATURE_K, StellarPhotometry } from '../../shared/astro/stellar';
|
||||
import { bodyTexturePath } from '../../shared/rendering/texture-catalog';
|
||||
import { BodyRecord } from '../../shared/models/body.model';
|
||||
import { ExoplanetRecord } from '../../shared/models/exoplanet.model';
|
||||
import { StarRecord } from '../../shared/models/star.model';
|
||||
import { StarRecord, SUN_STAR_ID } from '../../shared/models/star.model';
|
||||
import { BodyDetailViewModel } from './body-detail.model';
|
||||
|
||||
/** Everything the view model is assembled from — the three catalogues, already loaded. */
|
||||
@@ -16,17 +16,88 @@ export interface BodyCatalogues {
|
||||
|
||||
/**
|
||||
* Bolometric luminosity of a star in solar units, from what the catalogue measured: apparent
|
||||
* magnitude, parallax distance, and a bolometric correction read off the spectral type.
|
||||
* magnitude in its band, parallax distance, and a bolometric correction read off the colour, or
|
||||
* off the spectral type where there is no colour.
|
||||
*
|
||||
* None where no survey measured the star and its magnitude is the ETL's stand-in, which is all
|
||||
* 309 stars without a band have. The 265 archive hosts among them came out at a median 33 L☉
|
||||
* from it, KMT-2016-BLG-1107L, a 0.087 M☉ star, at 37, and OGLE-2005-BLG-390L b, published at
|
||||
* about 50 K, read 388 K. The Sun is the unit, whatever its magnitude is filed under.
|
||||
*/
|
||||
export function luminosityOf(star: StarRecord | undefined): number | null {
|
||||
if (!star) {
|
||||
return null;
|
||||
}
|
||||
return luminositySolar({
|
||||
return star && (star.magnitudeBand || star.id === SUN_STAR_ID) ? luminositySolar(photometryOf(star)) : null;
|
||||
}
|
||||
|
||||
function photometryOf(star: StarRecord): StellarPhotometry {
|
||||
return {
|
||||
magnitude: star.magnitude,
|
||||
distancePc: Math.hypot(star.x, star.y, star.z),
|
||||
spectralType: star.spectralType,
|
||||
});
|
||||
magnitudeBand: star.magnitudeBand,
|
||||
colorIndex: star.colorIndex,
|
||||
colorSystem: star.colorSystem,
|
||||
};
|
||||
}
|
||||
|
||||
/** How big, how hot and how bright a star is, and whether each was measured or derived here. */
|
||||
export interface StarSurface {
|
||||
/** Solar radii; `null` without a published radius, a measured magnitude, and a colour or type. */
|
||||
radiusSolar: number | null;
|
||||
radiusDerived: boolean;
|
||||
temperatureK: number | null;
|
||||
/** Solar luminosities, what its planets are warmed by; `null` where there is neither. */
|
||||
luminositySolar: number | null;
|
||||
luminosityDerived: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The temperature each host's disc is drawn at where the archive gives one, by star id: the first
|
||||
* among its planets, in their order, as {@link starSurfaceOf} takes it — for the star field, which
|
||||
* tints every star the colour of its own disc.
|
||||
*/
|
||||
export function publishedTemperaturesK(exoplanets: readonly ExoplanetRecord[]): Map<number, number> {
|
||||
const temperatures = new Map<number, number>();
|
||||
for (const { hostStarId, hostStarTemperatureK } of exoplanets) {
|
||||
if (hostStarId !== null && hostStarTemperatureK && !temperatures.has(hostStarId)) {
|
||||
temperatures.set(hostStarId, hostStarTemperatureK);
|
||||
}
|
||||
}
|
||||
return temperatures;
|
||||
}
|
||||
|
||||
/**
|
||||
* A star's radius, effective temperature and luminosity: the archive's `st_rad`, `st_teff` and
|
||||
* `st_lum` for a planet host, from any of its planets' rows, and otherwise derived — the
|
||||
* temperature off the dwarf sequence at the star's colour, the luminosity from its magnitude
|
||||
* (`luminosityOf`), the radius from those two (Stefan-Boltzmann). The Sun's are its own, the
|
||||
* nominal values the rest are measured in.
|
||||
*
|
||||
* Derived radii land within a factor of 1.5 of the archive's for 97 % of the 1 447 catalogue
|
||||
* hosts that have both, and within 0.018 dex at the median. Derived luminosities fare worse: 757
|
||||
* of the 4 440 hosts the archive gives one for were off by more than that factor, 667 of them
|
||||
* stars placed from the archive's own V and B−V, and Proxima read 8.9×10⁻⁴ L☉ against the
|
||||
* archive's 1.51×10⁻³ beside the radius and temperature it was drawn with, which imply 1.27×10⁻³.
|
||||
*/
|
||||
export function starSurfaceOf(star: StarRecord, planets: readonly ExoplanetRecord[]): StarSurface {
|
||||
if (star.id === SUN_STAR_ID) {
|
||||
return { radiusSolar: 1, radiusDerived: false, temperatureK: SOLAR_EFFECTIVE_TEMPERATURE_K, luminositySolar: 1, luminosityDerived: false };
|
||||
}
|
||||
const temperatureK = planets.find((planet) => planet.hostStarTemperatureK)?.hostStarTemperatureK ?? effectiveTemperatureK(photometryOf(star));
|
||||
const published = planets.find((planet) => planet.hostStarLuminositySolar)?.hostStarLuminositySolar;
|
||||
// None from a stand-in magnitude: PSR J1719-1438 came out 2.3 solar radii, wider than its
|
||||
// planet's orbit.
|
||||
const derived = luminosityOf(star);
|
||||
const luminosity = { luminositySolar: published ?? derived, luminosityDerived: published === undefined };
|
||||
const measured = planets.find((planet) => planet.hostStarRadiusSolar)?.hostStarRadiusSolar;
|
||||
if (measured) {
|
||||
return { radiusSolar: measured, radiusDerived: false, temperatureK, ...luminosity };
|
||||
}
|
||||
return {
|
||||
radiusSolar: derived !== null && temperatureK !== null ? radiusFromLuminositySolar(derived, temperatureK) : null,
|
||||
radiusDerived: true,
|
||||
temperatureK,
|
||||
...luminosity,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,7 +148,11 @@ export function buildBodyViewModel(id: string, catalogues: BodyCatalogues): Body
|
||||
massEarth: exoplanet.massEarth,
|
||||
discoveryYear: exoplanet.discoveryYear,
|
||||
orbit: exoplanet.orbit,
|
||||
appearance: appearanceForExoplanet(exoplanet, luminosityOf(hostStar)),
|
||||
// Warmed by what the system view warms it by: the archive's luminosity where it has one.
|
||||
appearance: appearanceForExoplanet(
|
||||
exoplanet,
|
||||
hostStar ? starSurfaceOf(hostStar, catalogues.exoplanets.filter((candidate) => candidate.hostStarId === hostStar.id)).luminositySolar : null,
|
||||
),
|
||||
hasPhotography: bodyTexturePath(exoplanet.id) !== undefined,
|
||||
imaged: exoplanet.imaged,
|
||||
// `periodDays` is populated for none of the shipped records, and deriving one would need the
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { ArticleService } from '../../core/data/article.service';
|
||||
import { PlanetAppearance } from '../../shared/astro/planet-appearance';
|
||||
import { BodyDetailViewModel } from './body-detail.model';
|
||||
import { InfoPanelComponent } from './info-panel.component';
|
||||
|
||||
const planet: BodyDetailViewModel = {
|
||||
id: '2MASS J21252752-8138278 b',
|
||||
name: '2MASS J21252752-8138278 b',
|
||||
kind: 'exoplanet',
|
||||
hostStarName: '2MASS J21252752-8138278',
|
||||
orbit: { semiMajorAxisAu: 7493 },
|
||||
appearance: { planetClass: 'gasGiant', palette: { structure: 'banded' }, equilibriumTemperatureK: 2.74, bulkDensityGramsPerCm3: null, polarCapExtentDeg: 0, seed: 1 } as unknown as PlanetAppearance,
|
||||
hasPhotography: false
|
||||
};
|
||||
|
||||
describe('InfoPanelComponent', () => {
|
||||
let fixture: ComponentFixture<InfoPanelComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [InfoPanelComponent],
|
||||
providers: [provideRouter([]), { provide: ArticleService, useValue: { lookup: async () => ({ status: 'none' }) } }]
|
||||
}).compileComponents();
|
||||
fixture = TestBed.createComponent(InfoPanelComponent);
|
||||
fixture.componentRef.setInput('body', planet);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('wraps a long designation rather than cutting off the digits that tell it apart', () => {
|
||||
const host = fixture.nativeElement as HTMLElement;
|
||||
const heading = host.querySelector('h1')!;
|
||||
const eyebrow = heading.nextElementSibling!;
|
||||
expect(heading.textContent?.trim()).toBe('2MASS J21252752-8138278 b');
|
||||
for (const line of [heading, eyebrow]) {
|
||||
expect(line.classList).not.toContain('truncate');
|
||||
expect(line.classList).toContain('wrap-break-word');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -35,8 +35,10 @@ import { ReadoutSectionsComponent } from './readout-sections.component';
|
||||
|
||||
<header class="flex items-start gap-2 px-4 pt-4 pb-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h1 class="truncate text-lg leading-tight font-bold tracking-[0.04em] text-text uppercase">{{ body().name }}</h1>
|
||||
<p class="type-eyebrow mt-1 truncate text-accent">{{ readouts().kindLabel }} · {{ body().hostStarName }}</p>
|
||||
<!-- Wrapped, not truncated: a designation's last digits are the ones that tell it from its
|
||||
neighbours, and an ellipsis took exactly those off "2MASS J21252752-8138278 b". -->
|
||||
<h1 class="text-lg leading-tight font-bold tracking-[0.04em] wrap-break-word text-text uppercase">{{ body().name }}</h1>
|
||||
<p class="type-eyebrow mt-1 wrap-break-word text-accent">{{ readouts().kindLabel }} · {{ body().hostStarName }}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -16,10 +16,16 @@ import { TimeStore } from '../../shared/state/time.store';
|
||||
import { LinkBudget } from '../../shared/astro/jump-links';
|
||||
import { HudDisplay } from '../hud/hud-dock.component';
|
||||
import { GalaxySystemSceneComponent } from './galaxy-system-scene.component';
|
||||
import { normalView, positionViewDirection } from 'three/tsl';
|
||||
import { galacticNormal } from './grid-plane';
|
||||
import { catalogueCensus, positionsNote } from './star-readouts';
|
||||
import { closestApproachAu, SUN_RADIUS_AU, systemFramingDistanceAu } from './system-framing';
|
||||
import { blackbodyColor, SOLAR_EFFECTIVE_TEMPERATURE_K } from '../../shared/astro/stellar';
|
||||
import { appearanceForExoplanet } from '../../shared/astro/body-appearance';
|
||||
import { planetTexture } from '../../shared/rendering/procedural-planet-texture';
|
||||
import { loadCachedTexture, SUN_TEXTURE_PATH } from '../../shared/rendering/texture-catalog';
|
||||
import { JumpLinkRenderer } from './jump-link-renderer';
|
||||
import { StarFieldRenderer } from './star-field-renderer';
|
||||
import { systemFramingDistanceAu } from './system-framing';
|
||||
import { SystemOrbitsRenderer } from './system-orbits-renderer';
|
||||
import { LabeledPoint, StarLabelOverlay } from './star-label-overlay';
|
||||
|
||||
@@ -36,9 +42,32 @@ const ALPHA_CENTAURI: StarRecord = { id: 1, name: 'Alpha Centauri', x: 1.34, y:
|
||||
// Its id deliberately differs from its place in STARS, so a lookup by id cannot pass for one by index.
|
||||
const PROXIMA: StarRecord = { id: 42, name: 'Proxima Centauri', x: 0, y: 1.3, z: 0, magnitude: 11.1, spectralType: 'M5V', colorIndex: 1.8 };
|
||||
|
||||
const STARS: StarRecord[] = [SUN, ALPHA_CENTAURI, PROXIMA];
|
||||
// A supergiant nothing publishes a radius for, and a white dwarf with neither a colour nor a type
|
||||
// the parser reads, for what the system view draws each star at; last, so the indices above hold.
|
||||
const ANTARES: StarRecord = { id: 80519, name: 'Antares', x: -58.54, y: -140.31, z: -75.57, magnitude: 1.06, magnitudeBand: 'V', spectralType: 'M1Ib + B2.5V', colorIndex: 1.865, colorSystem: 'B-V' };
|
||||
const PROCYON_B: StarRecord = { id: 37279, name: 'Gl 280B', x: -1.08, y: 3.19, z: 0.34, magnitude: 10.7, magnitudeBand: 'V', spectralType: 'DA', colorIndex: null };
|
||||
|
||||
// A host only the archive places, at the stand-in magnitude, with no type and no colour.
|
||||
const LENS: StarRecord = { id: 1070000536, name: 'KMT-2016-BLG-1107L', x: 6651, y: 0, z: 0, magnitude: 15, spectralType: 'Unknown', colorIndex: null, source: 'exoplanet-archive' };
|
||||
|
||||
const STARS: StarRecord[] = [SUN, ALPHA_CENTAURI, PROXIMA, ANTARES, PROCYON_B, LENS];
|
||||
const STAR_POSITIONS = new Float32Array(STARS.flatMap((star) => [star.x, star.y, star.z]));
|
||||
|
||||
// Proxima's planet as the archive gives it, with its host's radius, temperature and luminosity.
|
||||
const PROXIMA_B: ExoplanetRecord = {
|
||||
id: 'Proxima Cen b',
|
||||
hostStarId: PROXIMA.id,
|
||||
hostStarName: 'Proxima Cen',
|
||||
name: 'Proxima Cen b',
|
||||
hostStarRadiusSolar: 0.141,
|
||||
hostStarTemperatureK: 2900,
|
||||
hostStarLuminositySolar: 0.00151,
|
||||
orbit: { semiMajorAxisAu: 0.0485, eccentricity: 0.02 }
|
||||
};
|
||||
|
||||
// A microlensing planet, whose host the archive places 6.7 kpc out.
|
||||
const LENS_B: ExoplanetRecord = { id: 'KMT-2016-BLG-1107L b', hostStarId: LENS.id, hostStarName: 'KMT-2016-BLG-1107L', name: 'KMT-2016-BLG-1107L b', orbit: {} };
|
||||
|
||||
const DEEP_SKY_OBJECT: DeepSkyRecord = {
|
||||
id: 'NGC0224',
|
||||
name: 'Andromeda Galaxy',
|
||||
@@ -166,7 +195,7 @@ class FakeDataLoaderService {
|
||||
}
|
||||
|
||||
loadExoplanets(): Promise<ExoplanetRecord[]> {
|
||||
return Promise.resolve([]);
|
||||
return Promise.resolve([PROXIMA_B, LENS_B]);
|
||||
}
|
||||
|
||||
loadDeepSky(): Promise<DeepSkyRecord[]> {
|
||||
@@ -217,6 +246,12 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
|
||||
await flushAsync();
|
||||
});
|
||||
|
||||
it('rings the systems inside the survey edge, and puts them first in the draw budget, not a host kiloparsecs out', () => {
|
||||
const scene = fixture.componentInstance as unknown as { hostRings: { count: number }; hostStars: Uint8Array };
|
||||
expect(scene.hostRings.count).toBe(2);
|
||||
expect([...scene.hostStars]).toEqual(STARS.map((star) => (star === SUN || star === PROXIMA ? 1 : 0)));
|
||||
});
|
||||
|
||||
it('starts in the galaxy view with the system group hidden', () => {
|
||||
const component = fixture.componentInstance as unknown as { galaxyGroup: THREE.Group; systemGroup: THREE.Group };
|
||||
expect(component.galaxyGroup.visible).toBe(true);
|
||||
@@ -276,8 +311,9 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
|
||||
expect(refocus).toHaveBeenCalledTimes(1);
|
||||
const [focus] = refocus.mock.calls[0];
|
||||
expect(focus.view).toBeDefined();
|
||||
// The Sun has Earth, so it is a host; the others have nothing catalogued.
|
||||
expect(Array.from(focus.hosts ?? [])).toEqual([1, 0, 0]);
|
||||
// The Sun has Earth and Proxima its b, so both are hosts; the lens has a planet too, but 6.7 kpc
|
||||
// out, past the survey edge; the others have nothing catalogued.
|
||||
expect(Array.from(focus.hosts ?? [])).toEqual([1, 0, 1, 0, 0, 0]);
|
||||
});
|
||||
|
||||
it('chooses again once the camera has turned half the margin, and not for less', async () => {
|
||||
@@ -1043,6 +1079,189 @@ describe('GalaxySystemSceneComponent camera-flight transitions', () => {
|
||||
expect(navigationStore.viewLevel()).toBe('galactic');
|
||||
});
|
||||
|
||||
it('names what the neighbourhood holds, and where the positions of the stars in it come from', async () => {
|
||||
await advanceFrames(engine, 0.3);
|
||||
const component = fixture.componentInstance as unknown as { hudSubtitle(): string; hudNote(): string };
|
||||
expect(component.hudSubtitle()).toBe(catalogueCensus(STARS));
|
||||
expect(component.hudNote()).toBe(positionsNote(STARS));
|
||||
expect(component.hudNote()).toContain('1 planet hosts only the NASA Exoplanet Archive places, from its distances');
|
||||
});
|
||||
|
||||
it('offers a star for a route by its classification, and a star with none by its name alone', () => {
|
||||
const component = fixture.componentInstance as unknown as {
|
||||
starSearchIndex(): { entry: { starId?: number; subtitle: string } }[];
|
||||
routeOptions(): { id: number; subtitle: string }[];
|
||||
onRouteQuery(query: string): void;
|
||||
currentStarOption(): { subtitle: string } | null;
|
||||
};
|
||||
const optionFor = (query: string, id: number): string | undefined => {
|
||||
component.onRouteQuery(query);
|
||||
return component.routeOptions().find((option) => option.id === id)?.subtitle;
|
||||
};
|
||||
expect(optionFor('KMT-2016', LENS.id)).toBe('');
|
||||
expect(optionFor('Proxima', PROXIMA.id)).toBe('M5V');
|
||||
// Classified for the options shown only: the index holds none, where all 455 571 stars cost 140-230 ms at boot.
|
||||
expect(component.starSearchIndex().every(({ entry }) => entry.subtitle === '')).toBe(true);
|
||||
navigationStore.selectStar(LENS.id);
|
||||
expect(component.currentStarOption()?.subtitle).toBe('');
|
||||
navigationStore.selectStar(PROXIMA.id);
|
||||
expect(component.currentStarOption()?.subtitle).toBe('M5V');
|
||||
});
|
||||
|
||||
describe('each star at its own size and in its own colour', () => {
|
||||
type DrawnStar = {
|
||||
starMarkerGeometry: THREE.SphereGeometry;
|
||||
starTint: { value: THREE.Color };
|
||||
controls: { minDistance: number };
|
||||
systemRenderer: { object: THREE.Object3D };
|
||||
hudReadouts(): { label: string; value: string; derived?: boolean }[];
|
||||
};
|
||||
|
||||
async function enter(star: StarRecord): Promise<DrawnStar> {
|
||||
navigationStore.selectStar(star.id);
|
||||
await flushAsync();
|
||||
await advanceFrames(engine, 2.5);
|
||||
return fixture.componentInstance as unknown as DrawnStar;
|
||||
}
|
||||
|
||||
function expectColour(colour: THREE.Color, [red, green, blue]: readonly number[]): void {
|
||||
expect([colour.r, colour.g, colour.b].map((channel) => channel.toFixed(4))).toEqual([red, green, blue].map((channel) => channel.toFixed(4)));
|
||||
}
|
||||
|
||||
it('draws a host at the radius and in the colour the archive gives it, lights its planets in its light, and says how bright it is', async () => {
|
||||
const scene = await enter(PROXIMA);
|
||||
expect(scene.starMarkerGeometry.parameters.radius).toBeCloseTo(0.141 * SUN_RADIUS_AU, 12);
|
||||
expectColour(scene.starTint.value, blackbodyColor(2900));
|
||||
const light = scene.systemRenderer.object.children.find((child): child is THREE.PointLight => child instanceof THREE.PointLight)!;
|
||||
expectColour(light.color, blackbodyColor(2900, SOLAR_EFFECTIVE_TEMPERATURE_K));
|
||||
expect(scene.hudReadouts().find((readout) => readout.label === 'Luminosity')).toEqual({ label: 'Luminosity', value: '0.0015 L☉' });
|
||||
expect(scene.hudReadouts().find((readout) => readout.label === 'Radius')?.value).toBe('0.141 solar radii');
|
||||
});
|
||||
|
||||
it("draws the disc as the Sun's photograph in the star's colour, darkened towards the limb", async () => {
|
||||
// The disc's parts, read off the material's node graph: the tint the star's temperature sets
|
||||
// and the photograph, times the limb factor, which is worked out below at a few angles.
|
||||
// Checking only that 0.6 was somewhere in the graph passed a limb brighter than the centre.
|
||||
const scene = await enter(PROXIMA);
|
||||
const colorNode = (scene as unknown as { starMarkerMaterial: THREE.MeshBasicNodeMaterial }).starMarkerMaterial.colorNode!;
|
||||
const nodes = new Set<THREE.Node>();
|
||||
const walk = (node: THREE.Node): void => {
|
||||
if (!nodes.has(node)) {
|
||||
nodes.add(node);
|
||||
for (const child of node.getChildren()) {
|
||||
walk(child);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(colorNode);
|
||||
const values = [...nodes].map((node) => (node as { value?: unknown }).value);
|
||||
expect(values).toContain(scene.starTint.value);
|
||||
expect(values).toContain(loadCachedTexture(SUN_TEXTURE_PATH));
|
||||
|
||||
// The factor the photograph is multiplied by, as the shader computes it where the cosine
|
||||
// between the surface normal and the line of sight is `cosine`. Only the operations a limb
|
||||
// law is written with are known; anything else fails rather than being guessed at.
|
||||
type Graph = { isConstNode?: boolean; isVarNode?: boolean; value?: number; op?: string; method?: string; node: Graph; aNode: Graph; bNode: Graph; cNode: Graph };
|
||||
const factorAt = (node: Graph, cosine: number): number => {
|
||||
const at = (child: Graph): number => factorAt(child, cosine);
|
||||
if (node.isVarNode) {
|
||||
return at(node.node);
|
||||
}
|
||||
if (node.isConstNode) {
|
||||
return node.value!;
|
||||
}
|
||||
switch (node.op ?? node.method) {
|
||||
case '+': return at(node.aNode) + at(node.bNode);
|
||||
case '-': return at(node.aNode) - at(node.bNode);
|
||||
case '*': return at(node.aNode) * at(node.bNode);
|
||||
case 'oneMinus': return 1 - at(node.aNode);
|
||||
case 'negate': return -at(node.aNode);
|
||||
case 'clamp': return Math.min(at(node.cNode), Math.max(at(node.bNode), at(node.aNode)));
|
||||
case 'dot':
|
||||
expect(node.aNode).toBe(normalView);
|
||||
expect(node.bNode).toBe(positionViewDirection);
|
||||
return cosine;
|
||||
default: throw new Error(`the limb factor has an operation the test cannot work out: ${node.op ?? node.method}`);
|
||||
}
|
||||
};
|
||||
// The graph is (photograph × tint) × factor, each step held in a variable.
|
||||
const factor = (colorNode as unknown as Graph).node.bNode;
|
||||
// The Sun's linear law, 1 − 0.6 (1 − μ): the centre at full brightness, the limb at 40 %, and
|
||||
// past the silhouette, where the normal turns away, no darker than the limb.
|
||||
expect(factorAt(factor, 1)).toBeCloseTo(1, 12);
|
||||
expect(factorAt(factor, 0.5)).toBeCloseTo(0.7, 12);
|
||||
expect(factorAt(factor, 0)).toBeCloseTo(0.4, 12);
|
||||
expect(factorAt(factor, -0.3)).toBeCloseTo(0.4, 12);
|
||||
});
|
||||
|
||||
it('tints a host in the star field the colour its disc is drawn in, at the temperature the archive gives it', async () => {
|
||||
// Proxima's B−V 1.8 reads 3 070 K; its disc is drawn at the archive's 2 900.
|
||||
const scene = await enter(PROXIMA);
|
||||
const field = (scene as unknown as { starField: { catalogueColors: Float32Array } }).starField.catalogueColors;
|
||||
const at = STARS.indexOf(PROXIMA) * 3;
|
||||
const disc = scene.starTint.value;
|
||||
expect([field[at], field[at + 1], field[at + 2]].map((channel) => channel.toFixed(4))).toEqual([disc.r, disc.g, disc.b].map((channel) => channel.toFixed(4)));
|
||||
});
|
||||
|
||||
it("warms a host's planets by the luminosity the archive gives it, in the system as on their own page", async () => {
|
||||
const scene = await enter(PROXIMA);
|
||||
const planet = (scene.systemRenderer as unknown as { members: { id: string; marker: THREE.Mesh }[] }).members.find((member) => member.id === PROXIMA_B.id)!;
|
||||
// The archive's 1.51×10⁻³ L☉ puts b at 228 K, temperate. The fixture has no measured band, so
|
||||
// without it b gets no temperature and another class, as it does in the Sun's light.
|
||||
const warmed = appearanceForExoplanet(PROXIMA_B, 0.00151);
|
||||
expect(warmed.planetClass).not.toBe(appearanceForExoplanet(PROXIMA_B, null).planetClass);
|
||||
expect(warmed.planetClass).not.toBe(appearanceForExoplanet(PROXIMA_B, 1).planetClass);
|
||||
expect((planet.marker.material as THREE.MeshStandardMaterial).map).toBe(planetTexture(warmed, { width: 128, height: 64 }));
|
||||
});
|
||||
|
||||
it('keeps the camera three radii out from a supergiant drawn at the radius its type and brightness give', async () => {
|
||||
const scene = await enter(ANTARES);
|
||||
const radius = scene.starMarkerGeometry.parameters.radius;
|
||||
// 410 R☉, 1.9 AU, at M1's 3 730 K: short of the 680 Ohnaka et al. (2013) measure, whose
|
||||
// luminosity is 0.4 dex above what V gives at the catalogue's distance.
|
||||
expect(radius / SUN_RADIUS_AU).toBeGreaterThan(350);
|
||||
expect(radius / SUN_RADIUS_AU).toBeLessThan(480);
|
||||
expect(scene.controls.minDistance).toBeCloseTo(closestApproachAu(radius), 9);
|
||||
expect(scene.controls.minDistance).toBeGreaterThan(5);
|
||||
// Settled where its disc stays inside the ring of its neighbours' names, not pressed up to it.
|
||||
expect(engine.getCamera().position.length()).toBeGreaterThan(1.2 * scene.controls.minDistance);
|
||||
});
|
||||
|
||||
it("frames a supergiant on a phone by the canvas's own size, so its neighbours' names stay off its disc", async () => {
|
||||
// A 390x844 canvas: framed as on a desktop, at half the half-side, the disc reached 98 px
|
||||
// from the centre and the names hung in from their ring came to 70.
|
||||
const canvas = (fixture.nativeElement as HTMLElement).querySelector('canvas')!;
|
||||
Object.defineProperty(canvas, 'clientWidth', { value: 390 });
|
||||
Object.defineProperty(canvas, 'clientHeight', { value: 844 });
|
||||
const scene = await enter(ANTARES);
|
||||
const camera = engine.getPerspectiveCamera();
|
||||
const gridOuterRadiusAu = (scene.systemRenderer as unknown as { gridOuterRadiusAu: number }).gridOuterRadiusAu;
|
||||
const radius = scene.starMarkerGeometry.parameters.radius;
|
||||
const onPhone = systemFramingDistanceAu(gridOuterRadiusAu, { fovDegrees: camera.fov, aspect: camera.aspect, shorterSidePx: 390 }, radius);
|
||||
expect(onPhone).toBeGreaterThan(1.5 * systemFramingDistanceAu(gridOuterRadiusAu, { fovDegrees: camera.fov, aspect: camera.aspect }, radius));
|
||||
expect(engine.getCamera().position.length()).toBeCloseTo(onPhone, 6);
|
||||
});
|
||||
|
||||
it('frames it by the shorter side on a phone held sideways too', async () => {
|
||||
// 844x390: framed by the width, the disc took the desktop's half again, 8.49 AU out, not 14.97.
|
||||
const canvas = (fixture.nativeElement as HTMLElement).querySelector('canvas')!;
|
||||
Object.defineProperty(canvas, 'clientWidth', { value: 844 });
|
||||
Object.defineProperty(canvas, 'clientHeight', { value: 390 });
|
||||
const scene = await enter(ANTARES);
|
||||
const camera = engine.getPerspectiveCamera();
|
||||
const gridOuterRadiusAu = (scene.systemRenderer as unknown as { gridOuterRadiusAu: number }).gridOuterRadiusAu;
|
||||
const radius = scene.starMarkerGeometry.parameters.radius;
|
||||
expect(engine.getCamera().position.length()).toBeCloseTo(systemFramingDistanceAu(gridOuterRadiusAu, { fovDegrees: camera.fov, aspect: camera.aspect, shorterSidePx: 390 }, radius), 6);
|
||||
});
|
||||
|
||||
it('draws a star nothing gives a size or temperature for as a grey point, not as the Sun', async () => {
|
||||
const scene = await enter(PROCYON_B);
|
||||
expect(scene.starMarkerGeometry.parameters.radius).toBeLessThan(SUN_RADIUS_AU / 1000);
|
||||
expectColour(scene.starTint.value, [1, 1, 1]);
|
||||
expect(scene.hudReadouts().some((readout) => readout.label === 'Radius')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores a new selection while a transition is already in flight, then resolves to the latest requested star once idle', async () => {
|
||||
navigationStore.selectStar(SUN.id);
|
||||
await flushAsync();
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import * as THREE from 'three/webgpu';
|
||||
import { normalView, positionViewDirection, texture, uniform } from 'three/tsl';
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
|
||||
import {
|
||||
@@ -19,6 +20,8 @@ import {
|
||||
galacticCentrePositionPc,
|
||||
galacticToEquatorial,
|
||||
} from '../../shared/astro/galaxy';
|
||||
import { spectralClassification } from '../../shared/astro/spectral';
|
||||
import { blackbodyColor } from '../../shared/astro/stellar';
|
||||
import { DataLoaderService } from '../../core/data/data-loader.service';
|
||||
import { EngineService, SceneCamera } from '../../core/engine/engine.service';
|
||||
import { BodyRecord, RotationalElements } from '../../shared/models/body.model';
|
||||
@@ -33,7 +36,7 @@ import {
|
||||
SUN_TEXTURE_PATH,
|
||||
} from '../../shared/rendering/texture-catalog';
|
||||
import { isDesignation } from '../../shared/models/star-catalog';
|
||||
import { StarRecord } from '../../shared/models/star.model';
|
||||
import { StarRecord, SUN_STAR_ID } from '../../shared/models/star.model';
|
||||
import { Bookmark } from '../../shared/state/bookmarks.store';
|
||||
import { NavigationStore, ViewLevel } from '../../shared/state/navigation.store';
|
||||
import { TimeStore } from '../../shared/state/time.store';
|
||||
@@ -42,13 +45,14 @@ import { DeepSkyRenderer } from './deep-sky-renderer';
|
||||
import { galacticNormal, PolarGridPlane, TetherField } from './grid-plane';
|
||||
import { MilkyWayRenderer } from './milky-way-renderer';
|
||||
import {
|
||||
starMarkerRadiusAu,
|
||||
closestApproachAu,
|
||||
NEIGHBOUR_RING_FRACTION,
|
||||
SUN_RADIUS_AU,
|
||||
systemFrameRadiusAu,
|
||||
systemFramingDistanceAu,
|
||||
systemViewDirection,
|
||||
} from './system-framing';
|
||||
import { formatAu, formatLuminosity, formatParsecs } from '../../shared/format/quantity';
|
||||
import { formatAu, formatParsecs } from '../../shared/format/quantity';
|
||||
import {
|
||||
distanceRings,
|
||||
formatRoundLength,
|
||||
@@ -57,7 +61,7 @@ import {
|
||||
type ScaleBar,
|
||||
} from '../../shared/format/scale-bar';
|
||||
import { BodyDetailViewModel } from '../body-detail/body-detail.model';
|
||||
import { buildBodyViewModel, luminosityOf } from '../body-detail/body-view-model';
|
||||
import { buildBodyViewModel, publishedTemperaturesK, starSurfaceOf, StarSurface } from '../body-detail/body-view-model';
|
||||
import {
|
||||
DEFAULT_HUD_DISPLAY,
|
||||
HudDisplay,
|
||||
@@ -65,12 +69,11 @@ import {
|
||||
HudReadout,
|
||||
} from '../hud/hud-dock.component';
|
||||
import { RouteRequest, RouteResult, RouteStarOption } from '../hud/routes-panel.component';
|
||||
import { buildSearchIndex, IndexedSearchEntry, rankSearchResults } from '../search/search-ranking';
|
||||
import { buildSearchIndex, entrySubtitle, IndexedSearchEntry, rankSearchResults } from '../search/search-ranking';
|
||||
import { StarmapHudComponent } from './starmap-hud.component';
|
||||
import { SystemObjectCardComponent } from './system-object-card.component';
|
||||
import { RoutingClient } from './routing-client';
|
||||
import {
|
||||
colorIndexToRgb,
|
||||
FOCUS_RADIUS_PC,
|
||||
StarFieldRenderer,
|
||||
starRenderBudgetFromUrl,
|
||||
@@ -85,12 +88,47 @@ import { JumpLinkRenderer } from './jump-link-renderer';
|
||||
import { ReservedBox, ringPlacement } from './label-ring';
|
||||
import { LabeledPoint, LabelSide, StarLabelOverlay } from './star-label-overlay';
|
||||
import { SystemOrbitsRenderer } from './system-orbits-renderer';
|
||||
import { catalogueCensus, positionsNote, starReadouts, starSubtitle } from './star-readouts';
|
||||
|
||||
/** HYG catalog id for the Sun itself — the only star we have a real close-up photo of. */
|
||||
const SOL_STAR_ID = 0;
|
||||
/** Radius, in CSS pixels, below which a body in the system view is scaled up to be seen at all. */
|
||||
const MIN_MARKER_PIXELS = 3;
|
||||
|
||||
/**
|
||||
* What a star nothing gives a radius for is drawn at: 150 km, far under the pixel floor from any
|
||||
* distance the camera can reach, so it is the floor's point, the size of no star in particular.
|
||||
* Drawn at the Sun's radius, PSR J1719-1438 — a neutron star, 10 km across — swallowed the planet
|
||||
* it holds at 0.0044 AU, and Procyon B, a white dwarf of 0.012 R☉, was drawn 81 times too wide.
|
||||
*/
|
||||
const UNMEASURED_STAR_RADIUS_AU = 1e-6;
|
||||
|
||||
/**
|
||||
* The linear limb-darkening coefficient: a star's surface is I(μ) = I(1) (1 − u (1 − μ)) bright,
|
||||
* where μ is the cosine of the angle between the line of sight and the surface normal. The Sun's
|
||||
* is about 0.6 in the visible, so its limb is 40 % as bright as its centre. Taken for every star,
|
||||
* although a hotter star's limb is somewhat brighter and a cooler one's darker.
|
||||
*/
|
||||
const LIMB_DARKENING = 0.6;
|
||||
|
||||
/**
|
||||
* The surface every star is drawn with: the Sun's photograph in grey, in `tint` — the colour of a
|
||||
* blackbody at the star's temperature — and darkened towards the limb. Unlit: it is the source.
|
||||
*
|
||||
* The pattern is the Sun's, standing in for a surface no telescope resolves on another star, at a
|
||||
* contrast turned down to the Sun's own (see `SUN_TEXTURE_PATH`). Its colour was taken out, so
|
||||
* the tint says what colour the star is rather than which filter the Sun was photographed in: the
|
||||
* Sun itself comes out the warm white of 5 772 K, not the pack's orange.
|
||||
*
|
||||
* The tint is a uniform, so every star shares one shader, compiled on the first system entry.
|
||||
*/
|
||||
function starSurfaceMaterial(tint: THREE.Node<'color'>): THREE.MeshBasicNodeMaterial {
|
||||
const material = new THREE.MeshBasicNodeMaterial();
|
||||
const mu = normalView.dot(positionViewDirection).clamp(0, 1);
|
||||
material.colorNode = texture(loadCachedTexture(SUN_TEXTURE_PATH))
|
||||
.rgb.mul(tint)
|
||||
.mul(mu.sub(1).mul(LIMB_DARKENING).add(1));
|
||||
return material;
|
||||
}
|
||||
|
||||
/**
|
||||
* How far from what the camera is looking at a star can be and still be named, as a fraction of
|
||||
* how far back the camera is — so the net widens as the view pulls out and closes as it dives
|
||||
@@ -183,11 +221,6 @@ const ROUTE_RANGE_CEILING_PC = MAX_JUMP_RANGE_PC;
|
||||
|
||||
/** How many neighbouring stars are named from inside a system. */
|
||||
const NEIGHBOUR_COUNT = 4;
|
||||
/**
|
||||
* How far out from the centre of the view a neighbour's name sits, as a fraction of the frame's
|
||||
* half-height. Clear of the scale rail at the top and the dock at the bottom.
|
||||
*/
|
||||
const NEIGHBOUR_RING_NDC = 0.74;
|
||||
/**
|
||||
* How far in front of the camera a neighbour's name is planted, in AU. Any depth projects to
|
||||
* the same place on the ring, but not to the same stability: unprojecting at the middle of the
|
||||
@@ -285,7 +318,6 @@ const GALACTIC_LEVEL_THRESHOLD = 0.5;
|
||||
|
||||
const SYSTEM_NEAR_AU = 0.002;
|
||||
const SYSTEM_FAR_AU = 20000;
|
||||
const SYSTEM_MIN_DISTANCE_AU = 0.05;
|
||||
const SYSTEM_MAX_DISTANCE_AU = 5000;
|
||||
/** Where the camera lands (AU) immediately after swapping into system space, pre-settle. */
|
||||
const SYSTEM_ENTRY_DISTANCE_AU = 200;
|
||||
@@ -420,8 +452,11 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
private readonly raycaster = new THREE.Raycaster();
|
||||
private readonly galaxyGroup = new THREE.Group();
|
||||
private readonly systemGroup = new THREE.Group();
|
||||
private readonly starMarkerMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff });
|
||||
/** Rebuilt per system, since the star's radius is derived from that system's innermost orbit. */
|
||||
/** The colour of the system's star, set on entering it; see `starSurfaceMaterial`. */
|
||||
private readonly starTint = uniform(new THREE.Color(1, 1, 1));
|
||||
/** One for every star, built on the first system entry, so its pipeline is compiled once. */
|
||||
private starMarkerMaterial?: THREE.MeshBasicNodeMaterial;
|
||||
/** Rebuilt per system, since every star has its own radius. */
|
||||
private starMarkerGeometry?: THREE.SphereGeometry;
|
||||
|
||||
/** Readout panel contents, refreshed on the same cadence as the labels rather than per frame. */
|
||||
@@ -494,7 +529,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
return rankSearchResults(index, query, ROUTE_OPTION_COUNT).flatMap((entry) =>
|
||||
entry.starId === undefined
|
||||
? []
|
||||
: [{ id: entry.starId, name: entry.name, subtitle: entry.subtitle }],
|
||||
: [{ id: entry.starId, name: entry.name, subtitle: entrySubtitle(entry) }],
|
||||
);
|
||||
});
|
||||
private readonly routeQuery = signal('');
|
||||
@@ -539,6 +574,9 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
private galacticStrength = 0;
|
||||
private labelOverlay?: StarLabelOverlay;
|
||||
private stars: readonly StarRecord[] = [];
|
||||
/** The neighbourhood's subtitle: what the catalogue holds, by the catalogue describing it. */
|
||||
private catalogueCensus = '';
|
||||
private positionsNote = '';
|
||||
private starsById = new Map<number, StarRecord>();
|
||||
private bodies: readonly BodyRecord[] = [];
|
||||
private exoplanets: readonly ExoplanetRecord[] = [];
|
||||
@@ -555,6 +593,8 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
private currentStarId: number | null = null;
|
||||
private systemRenderer?: SystemOrbitsRenderer;
|
||||
private starMarker?: THREE.Mesh;
|
||||
/** The system's star's radius and temperature, worked out once on entering it. */
|
||||
private currentStarSurface?: StarSurface;
|
||||
/** How the star marker is turned: the Sun's IAU elements for the Sun, nothing for any other star. */
|
||||
private starRotation?: RotationalElements;
|
||||
|
||||
@@ -604,9 +644,8 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
this.tethers?.dispose();
|
||||
this.labelOverlay?.dispose();
|
||||
this.systemRenderer?.dispose();
|
||||
(this.starMarker?.material as THREE.Material | undefined)?.dispose();
|
||||
this.starMarkerGeometry?.dispose();
|
||||
this.starMarkerMaterial.dispose();
|
||||
this.starMarkerMaterial?.dispose();
|
||||
this.engine.dispose();
|
||||
}
|
||||
|
||||
@@ -688,6 +727,8 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
}),
|
||||
]);
|
||||
this.stars = stars;
|
||||
this.catalogueCensus = catalogueCensus(stars);
|
||||
this.positionsNote = positionsNote(stars);
|
||||
this.starsById = new Map(stars.map((star) => [star.id, star]));
|
||||
this.neighbourhood = new StarNeighbourhood(stars);
|
||||
this.routing = new RoutingClient(stars, positions, this.neighbourhood);
|
||||
@@ -697,7 +738,8 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
stars.map((star) => ({
|
||||
kind: 'star' as const,
|
||||
name: star.name,
|
||||
subtitle: star.spectralType,
|
||||
subtitle: '',
|
||||
star,
|
||||
starId: star.id,
|
||||
})),
|
||||
),
|
||||
@@ -725,15 +767,17 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
positions,
|
||||
starRenderBudgetFromUrl(window.location.search),
|
||||
this.starsByBrightness,
|
||||
publishedTemperaturesK(exoplanets),
|
||||
);
|
||||
this.hostStars = Uint8Array.from(stars, (star) =>
|
||||
this.starIdsWithBodies.has(star.id) ? 1 : 0,
|
||||
);
|
||||
// Ringed, and drawn ahead of the brightness tier, only inside the survey edge. The 2 883 hosts
|
||||
// past it, 2 878 of them placed by the archive and 916 beyond a kiloparsec, mostly the Kepler
|
||||
// field's, were a band of 1 687 fixed-size rings over the opening view's lower right, on the
|
||||
// 250-350 pc grid labels, and read as neighbours; they still compete for the budget by brightness, and search
|
||||
// still enters them.
|
||||
const ringed = (star: StarRecord): boolean => this.starIdsWithBodies.has(star.id) && Math.hypot(star.x, star.y, star.z) <= SURVEY_EDGE_PC;
|
||||
this.hostStars = Uint8Array.from(stars, (star) => (ringed(star) ? 1 : 0));
|
||||
this.galaxyGroup.add(this.starField.object);
|
||||
this.hostRings = new HostStarRings(
|
||||
stars.filter((star) => this.starIdsWithBodies.has(star.id)),
|
||||
HUD_ACCENT,
|
||||
);
|
||||
this.hostRings = new HostStarRings(stars.filter(ringed), HUD_ACCENT);
|
||||
this.galaxyGroup.add(this.hostRings.object);
|
||||
this.jumpLinks = new JumpLinkRenderer(HUD_ACCENT);
|
||||
this.galaxyGroup.add(this.jumpLinks.object);
|
||||
@@ -1492,7 +1536,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
const canvas = this.canvasRef().nativeElement;
|
||||
const placed = ringPlacement(
|
||||
angle,
|
||||
NEIGHBOUR_RING_NDC,
|
||||
NEIGHBOUR_RING_FRACTION,
|
||||
{ width: canvas.clientWidth, height: canvas.clientHeight },
|
||||
this.reserved,
|
||||
);
|
||||
@@ -1758,23 +1802,15 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
const moonCount = this.bodies.filter(
|
||||
(body) => body.systemStarId === star.id && body.parentBodyId,
|
||||
).length;
|
||||
const distancePc = Math.hypot(star.x, star.y, star.z);
|
||||
const luminosity = luminosityOf(star);
|
||||
this.hudEyebrow.set('System');
|
||||
this.hudTitle.set(star.name);
|
||||
this.hudSubtitle.set(star.spectralType ? `Spectral type ${star.spectralType}` : '');
|
||||
this.hudSubtitle.set(starSubtitle(star));
|
||||
this.hudReadouts.set([
|
||||
{
|
||||
label: 'Bodies',
|
||||
value: moonCount > 0 ? `${planetCount} + ${moonCount} moons` : `${planetCount}`,
|
||||
},
|
||||
// Suppressed for the Sun rather than printed as `0.00 pc`, which is arithmetically right
|
||||
// and reads as a bug: the distance from here to here is not a measurement.
|
||||
...(distancePc > 0 ? [{ label: 'Distance', value: formatParsecs(distancePc) }] : []),
|
||||
{ label: 'Magnitude', value: star.magnitude.toFixed(2) },
|
||||
...(luminosity !== null
|
||||
? [{ label: 'Luminosity', value: formatLuminosity(luminosity), derived: true }]
|
||||
: []),
|
||||
...starReadouts(star, this.currentStarSurface),
|
||||
]);
|
||||
// Where the orbits come from, and for the Sun how far from the present they hold: each
|
||||
// body's card names its own source, and for a moon or an SBDB dwarf planet how far it strays from
|
||||
@@ -1819,7 +1855,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
|
||||
this.hudEyebrow.set('Solar Neighbourhood');
|
||||
this.hudTitle.set('Local Stars');
|
||||
this.hudSubtitle.set('Hipparcos · Yale Bright Star · Gliese');
|
||||
this.hudSubtitle.set(this.catalogueCensus);
|
||||
this.hudReadouts.set([
|
||||
// Both numbers, because they differ: the catalogue is what the map knows and the first is
|
||||
// what it draws. See `STAR_RENDER_BUDGET`.
|
||||
@@ -1837,9 +1873,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
// The one thing the field itself cannot show: which of those points can be flown into.
|
||||
{ label: 'Systems', value: `${this.enterableSystems}` },
|
||||
]);
|
||||
this.hudNote.set(
|
||||
'Positions from measured parallaxes. Grid marks the galactic plane through the Sun.',
|
||||
);
|
||||
this.hudNote.set(this.positionsNote);
|
||||
}
|
||||
|
||||
/** Where the current press started, so a drag can be told apart from a click. */
|
||||
@@ -1948,7 +1982,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
readonly currentStarOption = computed<RouteStarOption | null>(() => {
|
||||
const starId = this.navigationStore.selectedStarId();
|
||||
const star = starId === null ? undefined : this.starsById.get(starId);
|
||||
return star ? { id: star.id, name: star.name, subtitle: star.spectralType } : null;
|
||||
return star ? { id: star.id, name: star.name, subtitle: spectralClassification(star) } : null;
|
||||
});
|
||||
|
||||
onRouteQuery(query: string): void {
|
||||
@@ -2208,7 +2242,6 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
this.systemRenderer?.dispose();
|
||||
if (this.starMarker) {
|
||||
this.systemGroup.remove(this.starMarker);
|
||||
(this.starMarker.material as THREE.Material).dispose();
|
||||
}
|
||||
|
||||
const systemBodies = this.bodies.filter((body) => body.systemStarId === star.id);
|
||||
@@ -2218,58 +2251,54 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
// The star's own position is the line of sight to it, which is the plane the archive
|
||||
// measures exoplanet inclinations against. The Sun sits at the origin and has no
|
||||
// exoplanets, so it has no meaningful direction and the renderer falls back.
|
||||
// The star's luminosity, derived from its own catalogued magnitude and distance, is what
|
||||
// decides how hot each body in the system is — and so what each of them looks like.
|
||||
const hostLuminosity = luminosityOf(star);
|
||||
// Every star at its own radius: the archive's for a planet host, otherwise derived from its
|
||||
// colour and brightness — or a point, for the 2 858 stars with no measured magnitude or with
|
||||
// neither a colour nor a type, which the card then gives no radius. Its temperature is the
|
||||
// colour of its disc and of the light it casts, and its luminosity — the archive's, or else
|
||||
// derived from its magnitude and distance — decides how hot each body in the system is, and
|
||||
// so what each of them looks like.
|
||||
this.currentStarSurface = starSurfaceOf(star, systemExoplanets);
|
||||
this.systemRenderer = new SystemOrbitsRenderer(
|
||||
systemBodies,
|
||||
systemExoplanets,
|
||||
{ x: star.x, y: star.y, z: star.z },
|
||||
hostLuminosity,
|
||||
this.currentStarSurface.luminositySolar,
|
||||
this.currentStarSurface.temperatureK,
|
||||
);
|
||||
this.systemGroup.add(this.systemRenderer.object);
|
||||
this.applyDisplay(this.display());
|
||||
|
||||
const starRadiusAu = this.currentStarSurface.radiusSolar === null ? UNMEASURED_STAR_RADIUS_AU : this.currentStarSurface.radiusSolar * SUN_RADIUS_AU;
|
||||
|
||||
// Framed against the outermost thing drawn — the grid's outer ring, or an eccentric orbit's
|
||||
// aphelion where it runs past it — and against the camera this scene actually has, so the margin holds
|
||||
// whatever the window shape. Computed before the star, because how far away the star will be
|
||||
// seen from is what decides how big its halo has to be to stay visible.
|
||||
// aphelion where it runs past it — or against the star, for a giant wider than both; and
|
||||
// against the camera this scene actually has, so the margin holds whatever the window shape.
|
||||
// Framed against the perspective camera whichever is active: the framing distance is what
|
||||
// the orthographic frustum is then sized from, so both projections show the same extent.
|
||||
const framingCamera = this.engine.getPerspectiveCamera();
|
||||
const viewport = { fovDegrees: framingCamera.fov, aspect: framingCamera.aspect };
|
||||
const canvas = this.canvasRef().nativeElement;
|
||||
const viewport = { fovDegrees: framingCamera.fov, aspect: framingCamera.aspect, shorterSidePx: Math.min(canvas.clientWidth, canvas.clientHeight) };
|
||||
const framingDistance = systemFramingDistanceAu(
|
||||
this.systemRenderer.outermostRadiusAu,
|
||||
viewport,
|
||||
starRadiusAu,
|
||||
);
|
||||
|
||||
// The Sun at its own radius; every other star sized against its innermost orbit, which is all
|
||||
// the catalogue supports, and which at least never lets it swallow its own planets.
|
||||
const starRadiusAu =
|
||||
star.id === SOL_STAR_ID
|
||||
? SUN_RADIUS_AU
|
||||
: starMarkerRadiusAu(this.systemRenderer.minTopLevelSemiMajorAxisAu);
|
||||
this.starMarkerGeometry?.dispose();
|
||||
this.starMarkerGeometry = new THREE.SphereGeometry(starRadiusAu, 64, 32);
|
||||
|
||||
const starMarkerMaterial = this.starMarkerMaterial.clone();
|
||||
const starColor = colorIndexToRgb(star.colorIndex, star.spectralType);
|
||||
if (star.id === SOL_STAR_ID) {
|
||||
// The Sun is the only star we have (and could ever have) a real photograph of; every
|
||||
// other point in the galaxy view is far too distant to be resolved as a disk.
|
||||
starMarkerMaterial.map = loadCachedTexture(SUN_TEXTURE_PATH);
|
||||
starMarkerMaterial.color.set(0xffffff);
|
||||
} else {
|
||||
starMarkerMaterial.color.copy(starColor);
|
||||
}
|
||||
this.starMarkerMaterial ??= starSurfaceMaterial(this.starTint);
|
||||
// Grey, the photograph's own, where there is no temperature: the Sun's colour would say it is one.
|
||||
const temperatureK = this.currentStarSurface.temperatureK;
|
||||
const [red, green, blue] = temperatureK === null ? [1, 1, 1] : blackbodyColor(temperatureK);
|
||||
this.starTint.value.setRGB(red, green, blue, THREE.LinearSRGBColorSpace);
|
||||
// No halo. It was a sprite sized against the arrival frame — 1.12 AU for the Sun — so it
|
||||
// stayed put as the camera closed in and ended up filling the screen with the flat gradient
|
||||
// that was meant to dress the star, over the photograph underneath it.
|
||||
this.starMarker = new THREE.Mesh(this.starMarkerGeometry, starMarkerMaterial);
|
||||
this.starMarker = new THREE.Mesh(this.starMarkerGeometry, this.starMarkerMaterial);
|
||||
// Its pole 115 degrees from the one the IAU gives, and still, until it was turned like a planet.
|
||||
// The map's longitudes are Solar System Scope's, not Carrington's, so only the pole and the
|
||||
// 25.38-day turn are the Sun's own.
|
||||
this.starRotation = star.id === SOL_STAR_ID ? SUN_ROTATIONAL_ELEMENTS : undefined;
|
||||
this.starRotation = star.id === SUN_STAR_ID ? SUN_ROTATIONAL_ELEMENTS : undefined;
|
||||
this.systemGroup.add(this.starMarker);
|
||||
|
||||
this.galaxyGroup.visible = false;
|
||||
@@ -2286,7 +2315,7 @@ export class GalaxySystemSceneComponent implements AfterViewInit, OnDestroy {
|
||||
depthCamera.near = SYSTEM_NEAR_AU;
|
||||
depthCamera.far = SYSTEM_FAR_AU;
|
||||
depthCamera.updateProjectionMatrix();
|
||||
this.controls!.minDistance = SYSTEM_MIN_DISTANCE_AU;
|
||||
this.controls!.minDistance = closestApproachAu(starRadiusAu);
|
||||
this.controls!.maxDistance = SYSTEM_MAX_DISTANCE_AU;
|
||||
|
||||
this.resetZoom();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as THREE from 'three/webgpu';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { blackbodyColor, effectiveTemperatureK } from '../../shared/astro/stellar';
|
||||
import { StarRecord } from '../../shared/models/star.model';
|
||||
import { colorIndexToRgb, magnitudeToPointSize, selectDrawnStars, StarFieldRenderer } from './star-field-renderer';
|
||||
|
||||
@@ -63,9 +64,9 @@ describe('colorIndexToRgb', () => {
|
||||
});
|
||||
|
||||
it('matches the colour the same spectral type would give explicitly', () => {
|
||||
// K5 sits halfway between the K anchor (0.81) and the M anchor (1.40).
|
||||
// K5's row of the dwarf sequence is at B−V 1.15.
|
||||
const derived = colorIndexToRgb(null, 'K5');
|
||||
const explicit = colorIndexToRgb(1.105);
|
||||
const explicit = colorIndexToRgb(1.15);
|
||||
|
||||
expect(derived.r).toBeCloseTo(explicit.r, 6);
|
||||
expect(derived.g).toBeCloseTo(explicit.g, 6);
|
||||
@@ -85,6 +86,36 @@ describe('colorIndexToRgb', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('tints a G dwarf warm, in the colour its own disc is drawn in, and a giant at the temperature its type gives', () => {
|
||||
// G2 V: a blackbody at 5 770 K against D65, (1, 0.878, 0.821). The B−V ramp this replaced was
|
||||
// white at 0.8 and gave it (0.956, 0.969, 1), bluish beside its own disc.
|
||||
const g2 = colorIndexToRgb(0.823, 'Unknown', 'BP-RP');
|
||||
expect(g2.r).toBeGreaterThan(g2.b);
|
||||
expect([g2.r, g2.g, g2.b].map((channel) => channel.toFixed(5))).toEqual(blackbodyColor(5770).map((channel) => channel.toFixed(5)));
|
||||
const antares = { magnitude: 1.06, distancePc: 169.78, spectralType: 'M1Ib + B2.5V', colorIndex: 1.865, colorSystem: 'B-V' } as const;
|
||||
const giant = colorIndexToRgb(antares.colorIndex, antares.spectralType, antares.colorSystem);
|
||||
blackbodyColor(effectiveTemperatureK(antares)!).forEach((channel, i) => expect([giant.r, giant.g, giant.b][i]).toBeCloseTo(channel, 2));
|
||||
});
|
||||
|
||||
it('reads a BP−RP colour as the B−V of the dwarf of that colour, so a type tints alike in either', () => {
|
||||
// G2 V is B−V 0.65 or BP−RP 0.823, M0 V 1.42 or 1.84 (Pecaut & Mamajek).
|
||||
for (const [bMinusV, bpRp] of [[0.65, 0.823], [1.42, 1.84]]) {
|
||||
const [fromBpRp, fromBv] = [colorIndexToRgb(bpRp, undefined, 'BP-RP'), colorIndexToRgb(bMinusV)];
|
||||
expect([fromBpRp.r, fromBpRp.g, fromBpRp.b].map((channel) => channel.toFixed(5))).toEqual([fromBv.r, fromBv.g, fromBv.b].map((channel) => channel.toFixed(5)));
|
||||
expect(colorIndexToRgb(bpRp).b).toBeLessThan(fromBv.b);
|
||||
}
|
||||
});
|
||||
|
||||
it('draws a star measured in BP−RP in that colour', () => {
|
||||
const gaia = star({ id: 5, x: 0, y: 0, z: -10, colorIndex: 1.84, colorSystem: 'BP-RP', spectralType: 'Unknown' });
|
||||
const renderer = new StarFieldRenderer([gaia], packPositions([gaia]), 1);
|
||||
renderer.refocus({ centre: { x: 0, y: 0, z: 0 } });
|
||||
const { colorAttribute } = renderer as unknown as { colorAttribute: THREE.InstancedBufferAttribute };
|
||||
expect(colorAttribute.getZ(0)).toBeCloseTo(colorIndexToRgb(1.42).b, 5);
|
||||
expect(colorAttribute.getZ(0)).toBeCloseTo(blackbodyColor(3850)[2], 5);
|
||||
renderer.dispose();
|
||||
});
|
||||
|
||||
it('prefers a measured index over the spectral type', () => {
|
||||
const measured = colorIndexToRgb(-0.3, 'M5');
|
||||
expect(measured.b).toBeGreaterThan(measured.r);
|
||||
@@ -460,7 +491,7 @@ describe('StarFieldRenderer refocus', () => {
|
||||
|
||||
for (let instance = 0; instance < renderer.drawnCount; instance++) {
|
||||
const drawnStar = catalogue.find((candidate) => candidate.id === renderer.starIdAt(instance))!;
|
||||
const expected = colorIndexToRgb(drawnStar.colorIndex, drawnStar.spectralType);
|
||||
const expected = colorIndexToRgb(drawnStar.colorIndex, drawnStar.spectralType, drawnStar.colorSystem);
|
||||
expect(colorAttribute.getX(instance)).toBeCloseTo(expected.r, 5);
|
||||
expect(colorAttribute.getZ(instance)).toBeCloseTo(expected.b, 5);
|
||||
expect(sizeAttribute.getX(instance)).toBeGreaterThan(0);
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as THREE from 'three/webgpu';
|
||||
import { float, instancedBufferAttribute, mix, modelViewMatrix, smoothstep, uniform, uv, vec2, vec4 } from 'three/tsl';
|
||||
|
||||
import { BrightnessIndex, brightnessIndex, Positioned } from '../../shared/astro/brightest';
|
||||
import { spectralTypeToColorIndex } from '../../shared/astro/spectral';
|
||||
import { blackbodyColor, effectiveTemperatureK } from '../../shared/astro/stellar';
|
||||
import { SceneCamera } from '../../core/engine/engine.service';
|
||||
import { StarRecord } from '../../shared/models/star.model';
|
||||
import { PIXELS_TO_ANGULAR_SIZE, REFERENCE_FOV_DEGREES, REFERENCE_VIEWPORT_HEIGHT_PX } from './angular-size';
|
||||
@@ -97,30 +97,46 @@ export interface DrawFocus {
|
||||
|
||||
const SUN: Positioned = { x: 0, y: 0, z: 0 };
|
||||
|
||||
const COLD_STAR_COLOR = new THREE.Color(0.65, 0.75, 1.0);
|
||||
const NEUTRAL_STAR_COLOR = new THREE.Color(1.0, 1.0, 1.0);
|
||||
const WARM_STAR_COLOR = new THREE.Color(1.0, 0.6, 0.35);
|
||||
|
||||
/**
|
||||
* Crude but effective B-V color-index -> RGB tint: hot/blue stars (low/negative index) skew
|
||||
* blue-white, cool/red stars (high index) skew orange-red, matching real spectral colors.
|
||||
* A star's tint in the field: the colour of a blackbody at its effective temperature against the
|
||||
* display's white — the tint its own disc is drawn in, in its system. For a star with no planets
|
||||
* the temperature is the one the disc is drawn at (`effectiveTemperatureK`): off the dwarf sequence
|
||||
* at the star's colour, in B−V or Gaia's BP−RP, off the type where there is no colour, and off the
|
||||
* type for a giant. A host's disc is drawn at the archive's temperature instead, which the renderer
|
||||
* is given apart and tints it at ({@link temperatureTint}).
|
||||
*
|
||||
* `colorIndex` is `null` for the ~10% of stars HYG never photometered. Those fall back to a
|
||||
* value derived from `spectralType`, and to neutral white only when the catalog records no
|
||||
* classification at all — never to 0, which is itself a real color index meaning "hot A-type"
|
||||
* and would paint several hundred red dwarfs blue-white.
|
||||
* `colorIndex` is `null` for the ~10% of stars HYG never photometered. Those fall back to their
|
||||
* `spectralType`, and to neutral white only when the catalog records no classification at all —
|
||||
* never to 0, which is itself a real color index meaning "hot A-type" and would paint several
|
||||
* hundred red dwarfs blue-white.
|
||||
*
|
||||
* It was a ramp in B−V, white at 0.8, a K0 dwarf, where a blackbody against D65 is white at about
|
||||
* 6 500 K, B−V 0.44: of the 376 660 stars measured in BP−RP, 245 850 were tinted bluish, 227 797 of
|
||||
* them while their disc was warm, and a G2 dwarf (0.956, 0.969, 1) beside its disc's (1, 0.878,
|
||||
* 0.821). Its red end, (1, 0.6, 0.35), was paler than an M dwarf's disc.
|
||||
*/
|
||||
export function colorIndexToRgb(colorIndex: number | null, spectralType?: string): THREE.Color {
|
||||
const resolved = colorIndex ?? spectralTypeToColorIndex(spectralType);
|
||||
const color = new THREE.Color();
|
||||
if (resolved === null) {
|
||||
return color.copy(NEUTRAL_STAR_COLOR);
|
||||
}
|
||||
|
||||
const t = THREE.MathUtils.clamp((resolved + 0.4) / 2.4, 0, 1);
|
||||
return t < 0.5 ? color.lerpColors(COLD_STAR_COLOR, NEUTRAL_STAR_COLOR, t * 2) : color.lerpColors(NEUTRAL_STAR_COLOR, WARM_STAR_COLOR, (t - 0.5) * 2);
|
||||
export function colorIndexToRgb(colorIndex: number | null, spectralType?: string, colorSystem?: 'B-V' | 'BP-RP'): THREE.Color {
|
||||
// The distance is only there to say the star is not the Sun; the temperature reads none of the rest.
|
||||
return temperatureTint(effectiveTemperatureK({ magnitude: 0, distancePc: 1, spectralType, colorIndex, colorSystem }));
|
||||
}
|
||||
|
||||
/** The field's tint at a temperature: a blackbody against the display's white, neutral with none. */
|
||||
export function temperatureTint(temperatureK: number | null): THREE.Color {
|
||||
if (temperatureK === null) {
|
||||
return new THREE.Color().copy(NEUTRAL_STAR_COLOR);
|
||||
}
|
||||
// ponytail: the colour at the nearest 10 K, computed once. Rounding moves no channel by more than
|
||||
// 0.0014, and computing it for each of the 455 571 stars took 100 ms of the boot.
|
||||
const step = Math.round(temperatureK / BLACKBODY_STEP_K);
|
||||
const tint = (BLACKBODY_TINTS[step] ??= blackbodyColor(step * BLACKBODY_STEP_K));
|
||||
return new THREE.Color(tint[0], tint[1], tint[2]);
|
||||
}
|
||||
|
||||
const BLACKBODY_STEP_K = 10;
|
||||
const BLACKBODY_TINTS: [number, number, number][] = [];
|
||||
|
||||
/** Brighter stars (lower apparent magnitude) render as bigger points. */
|
||||
export function magnitudeToPointSize(magnitude: number): number {
|
||||
const t = THREE.MathUtils.clamp(1 - (magnitude + 2) / 12, 0, 1);
|
||||
@@ -281,7 +297,14 @@ export class StarFieldRenderer {
|
||||
private readonly catalogue: readonly StarRecord[],
|
||||
private readonly cataloguePositions: Float32Array,
|
||||
budget = STAR_RENDER_BUDGET,
|
||||
brightness?: BrightnessIndex
|
||||
brightness?: BrightnessIndex,
|
||||
/**
|
||||
* The temperature the archive gives each planet host, by star id, which its disc is drawn at
|
||||
* (`publishedTemperaturesK`). Tinted off its colour instead, 1 755 of the 4 485 hosts differed
|
||||
* from their own disc by more than 0.1 in RGB: Kepler-186 at 3 096 K in the field and 3 788 on
|
||||
* its disc, HD 97048 at 6 825 and 10 000.
|
||||
*/
|
||||
publishedTemperaturesK?: ReadonlyMap<number, number>
|
||||
) {
|
||||
this.budget = budget;
|
||||
this.brightness = brightness ?? brightnessIndex(catalogue);
|
||||
@@ -295,7 +318,8 @@ export class StarFieldRenderer {
|
||||
this.catalogueColors = new Float32Array(catalogue.length * 3);
|
||||
this.catalogueSizes = new Float32Array(catalogue.length);
|
||||
catalogue.forEach((star, index) => {
|
||||
const color = colorIndexToRgb(star.colorIndex, star.spectralType);
|
||||
const published = publishedTemperaturesK?.get(star.id);
|
||||
const color = published === undefined ? colorIndexToRgb(star.colorIndex, star.spectralType, star.colorSystem) : temperatureTint(published);
|
||||
this.catalogueColors[index * 3] = color.r;
|
||||
this.catalogueColors[index * 3 + 1] = color.g;
|
||||
this.catalogueColors[index * 3 + 2] = color.b;
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { StarRecord } from '../../shared/models/star.model';
|
||||
import { catalogueCensus, describingCatalogue, positionsNote, starReadouts, starSubtitle } from './star-readouts';
|
||||
|
||||
/** Three kinds of star the catalogue holds, each as the decoder gives it back. */
|
||||
const HYG_STAR: StarRecord = {
|
||||
id: 32263,
|
||||
name: 'Sirius',
|
||||
x: -0.49,
|
||||
y: 2.47,
|
||||
z: -0.75,
|
||||
magnitude: -1.44,
|
||||
magnitudeBand: 'V',
|
||||
spectralType: 'A0m',
|
||||
colorIndex: 0.009,
|
||||
colorSystem: 'B-V',
|
||||
distanceError: 0.004,
|
||||
distanceFromGaia: false,
|
||||
source: 'hyg'
|
||||
};
|
||||
// A star Gaia alone knows, at 117 pc and good to a tenth.
|
||||
const GAIA_STAR: StarRecord = {
|
||||
id: 1000000001,
|
||||
name: 'Gaia DR3 5612323414549657984',
|
||||
x: 117,
|
||||
y: 0,
|
||||
z: 0,
|
||||
magnitude: 11.2,
|
||||
magnitudeBand: 'G',
|
||||
spectralType: 'Unknown',
|
||||
colorIndex: 1.43,
|
||||
colorSystem: 'BP-RP',
|
||||
distanceError: 0.1,
|
||||
distanceFromGaia: true,
|
||||
source: 'gaia'
|
||||
};
|
||||
// HYG's description of Proxima, at the place and distance Gaia measures.
|
||||
const PLACED_BY_GAIA: StarRecord = { ...HYG_STAR, name: 'Proxima Centauri', magnitude: 11.01, colorIndex: 1.807, spectralType: 'M5Ve', distanceFromGaia: true, source: 'gaia' };
|
||||
|
||||
function value(readouts: { label: string; value: string }[], label: string): string | undefined {
|
||||
return readouts.find((readout) => readout.label === label)?.value;
|
||||
}
|
||||
|
||||
describe('starReadouts', () => {
|
||||
it('names the band of the magnitude, and which colour the colour index is', () => {
|
||||
expect(value(starReadouts(HYG_STAR), 'Magnitude')).toBe('V -1.44');
|
||||
expect(value(starReadouts(GAIA_STAR), 'Magnitude')).toBe('G 11.20');
|
||||
expect(value(starReadouts(HYG_STAR), 'Colour')).toBe('B−V 0.01');
|
||||
expect(value(starReadouts(GAIA_STAR), 'Colour')).toBe('BP−RP 1.43');
|
||||
});
|
||||
|
||||
it('says a colour read off a temperature was not measured', () => {
|
||||
const kepler445 = { ...HYG_STAR, magnitudeBand: 'G' as const, colorIndex: 1.66, colorFromTemperature: true, source: 'exoplanet-archive' };
|
||||
expect(starReadouts(kepler445).find((readout) => readout.label === 'Colour')).toEqual({ label: 'Colour', value: 'B−V 1.66, from its temperature', derived: true });
|
||||
});
|
||||
|
||||
it('says a stand-in magnitude was not measured, and leaves out a colour there is none of', () => {
|
||||
const unmeasured = starReadouts({ ...GAIA_STAR, magnitude: 12, magnitudeBand: undefined, colorIndex: null, colorSystem: undefined });
|
||||
expect(value(unmeasured, 'Magnitude')).toBe('Not measured');
|
||||
expect(value(unmeasured, 'Colour')).toBeUndefined();
|
||||
// Still Gaia's star, as the 44 Gaia sources with no G are: no band is not HYG's V.
|
||||
expect(value(unmeasured, 'Source')).toBe('Gaia DR3');
|
||||
});
|
||||
|
||||
it('gives the distance with its uncertainty', () => {
|
||||
expect(value(starReadouts(GAIA_STAR), 'Distance')).toBe('117 ± 12 pc');
|
||||
});
|
||||
|
||||
it("gives an archive star's distance error as the archive does, on the distance, not as a parallax range", () => {
|
||||
// KMT-2016-BLG-1836L, 7 100 +800 −2 400 pc: a mean of 22.5 %.
|
||||
const lens = { ...GAIA_STAR, x: 7100, magnitudeBand: undefined, colorIndex: null, colorSystem: undefined, distanceError: 0.2254, distanceFromGaia: false, source: 'exoplanet-archive' };
|
||||
expect(value(starReadouts(lens), 'Distance')).toBe('7.1 ± 1.6 kpc');
|
||||
expect(value(starReadouts({ ...lens, source: 'hyg' }), 'Distance')).toBe('5.8 kpc to 9.2 kpc');
|
||||
});
|
||||
|
||||
it('names the catalogue a star comes from, and whose distance it has', () => {
|
||||
expect(value(starReadouts(GAIA_STAR), 'Source')).toBe('Gaia DR3');
|
||||
expect(value(starReadouts(HYG_STAR), 'Source')).toBe('HYG');
|
||||
expect(value(starReadouts(PLACED_BY_GAIA), 'Source')).toBe('HYG, Gaia DR3 distance');
|
||||
expect(value(starReadouts({ ...HYG_STAR, source: 'exoplanet-archive' }), 'Source')).toBe('NASA Exoplanet Archive');
|
||||
});
|
||||
|
||||
it('says a radius was derived, and from what; a published one plainly', () => {
|
||||
expect(starReadouts(PLACED_BY_GAIA, { radiusSolar: 0.1049, radiusDerived: true, temperatureK: 3068, luminositySolar: null, luminosityDerived: true }).find((readout) => readout.label === 'Radius')).toEqual({
|
||||
label: 'Radius',
|
||||
value: '~0.10 solar radii, from colour and brightness',
|
||||
derived: true
|
||||
});
|
||||
expect(value(starReadouts(PLACED_BY_GAIA, { radiusSolar: 0.141, radiusDerived: false, temperatureK: 2900, luminositySolar: null, luminosityDerived: true }), 'Radius')).toBe('0.141 solar radii');
|
||||
// A giant's temperature is its type's whatever its colour, and so is a dwarf's with no colour.
|
||||
const betelgeuse = { ...HYG_STAR, name: 'Betelgeuse', spectralType: 'M1-M2Ia-Iab', colorIndex: 1.85 };
|
||||
expect(value(starReadouts(betelgeuse, { radiusSolar: 584.3, radiusDerived: true, temperatureK: 3590, luminositySolar: null, luminosityDerived: true }), 'Radius')).toBe('~580 solar radii, from its type and brightness');
|
||||
const gj3655 = { ...HYG_STAR, name: 'GJ 3655', spectralType: 'M8', colorIndex: null, colorSystem: undefined };
|
||||
expect(value(starReadouts(gj3655, { radiusSolar: 0.106, radiusDerived: true, temperatureK: 2570, luminositySolar: null, luminosityDerived: true }), 'Radius')).toBe('~0.11 solar radii, from its type and brightness');
|
||||
// Nor a dwarf's whose colour is off the table: HD 49748, G5 V at B−V −0.32.
|
||||
const hd49748 = { ...HYG_STAR, name: 'HD 49748', spectralType: 'G5V', colorIndex: -0.32 };
|
||||
expect(value(starReadouts(hd49748, { radiusSolar: 1.2, radiusDerived: true, temperatureK: 5660, luminositySolar: null, luminosityDerived: true }), 'Radius')).toBe('~1.2 solar radii, from its type and brightness');
|
||||
const kepler445 = { ...HYG_STAR, spectralType: 'M4', colorIndex: 1.66, colorFromTemperature: true, source: 'exoplanet-archive' };
|
||||
expect(value(starReadouts(kepler445, { radiusSolar: 0.21, radiusDerived: true, temperatureK: 3157, luminositySolar: null, luminosityDerived: true }), 'Radius')).toBe('~0.21 solar radii, from its temperature and brightness');
|
||||
expect(value(starReadouts(HYG_STAR, { radiusSolar: 1, radiusDerived: false, temperatureK: 5772, luminositySolar: null, luminosityDerived: true }), 'Radius')).toBe('1.00 solar radii');
|
||||
expect(starReadouts(HYG_STAR, { radiusSolar: null, radiusDerived: true, temperatureK: null, luminositySolar: null, luminosityDerived: true }).some((readout) => readout.label === 'Radius')).toBe(false);
|
||||
});
|
||||
|
||||
it('marks a derived luminosity so, and a published one not', () => {
|
||||
const surface = { radiusSolar: null, radiusDerived: true, temperatureK: null };
|
||||
expect(starReadouts(HYG_STAR, { ...surface, luminositySolar: 25.4, luminosityDerived: true }).find((readout) => readout.label === 'Luminosity')).toEqual({ label: 'Luminosity', value: '25.40 L☉', derived: true });
|
||||
expect(starReadouts(PLACED_BY_GAIA, { ...surface, luminositySolar: 0.00151, luminosityDerived: false }).find((readout) => readout.label === 'Luminosity')).toEqual({ label: 'Luminosity', value: '0.0015 L☉' });
|
||||
expect(starReadouts(HYG_STAR, { ...surface, luminositySolar: null, luminosityDerived: true }).some((readout) => readout.label === 'Luminosity')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('positionsNote', () => {
|
||||
it("says which positions are the archive's distances rather than parallaxes, and how many", () => {
|
||||
expect(positionsNote([HYG_STAR, GAIA_STAR])).toBe('Positions from measured parallaxes. Grid marks the galactic plane through the Sun.');
|
||||
expect(positionsNote([HYG_STAR, { ...GAIA_STAR, source: 'exoplanet-archive' }])).toBe(
|
||||
'Positions from measured parallaxes; for the 1 planet hosts only the NASA Exoplanet Archive places, from its distances. Grid marks the galactic plane through the Sun.'
|
||||
);
|
||||
});
|
||||
|
||||
it("says which stars sit at the Gliese catalogue's distances, many of them no parallax, and leaves the Sun out", () => {
|
||||
// A HYG row with neither a Hipparcos nor a Gaia error: GJ 3522, at the 4.46 pc of a parallax CNS3 estimates.
|
||||
const gliese: StarRecord = { ...HYG_STAR, id: 900, name: 'GJ 3522', distanceError: undefined };
|
||||
const sun: StarRecord = { ...HYG_STAR, id: 0, name: 'Sol', distanceError: undefined };
|
||||
expect(positionsNote([sun, HYG_STAR, gliese, { ...GAIA_STAR, source: 'exoplanet-archive' }])).toBe(
|
||||
'Positions from measured parallaxes; for the 1 stars only the Gliese catalogue places, from its distances, about half of them photometric; ' +
|
||||
'for the 1 planet hosts only the NASA Exoplanet Archive places, from its distances. Grid marks the galactic plane through the Sun.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('starSubtitle', () => {
|
||||
it("prints the catalogue's classification where it has one", () => {
|
||||
expect(starSubtitle(HYG_STAR)).toBe('Spectral type A0m');
|
||||
});
|
||||
|
||||
it("estimates one from the colour otherwise, in the colour's own system, and says so", () => {
|
||||
// 1.43 is a K5 dwarf in BP−RP; read as B−V it would be an M0.
|
||||
expect(starSubtitle(GAIA_STAR)).toBe('Spectral type ~K5, from colour');
|
||||
});
|
||||
|
||||
it('says an estimate came from the temperature where the colour was read off it', () => {
|
||||
// PSR J1719-1438: no magnitude in any colour, st_teff 4 500 K, B−V 1.13 off the dwarf sequence.
|
||||
const pulsar = { ...GAIA_STAR, source: 'exoplanet-archive', colorIndex: 1.128, colorSystem: 'B-V', colorFromTemperature: true } as const;
|
||||
expect(starSubtitle(pulsar)).toBe('Spectral type ~K5, from its temperature');
|
||||
});
|
||||
|
||||
it('prints nothing rather than "Unknown" when there is neither', () => {
|
||||
expect(starSubtitle({ ...GAIA_STAR, colorIndex: null, colorSystem: undefined })).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('catalogueCensus', () => {
|
||||
it('counts the stars by the catalogue describing them, not by whose position they have', () => {
|
||||
expect(describingCatalogue(PLACED_BY_GAIA)).toBe('HYG');
|
||||
expect(catalogueCensus([GAIA_STAR, GAIA_STAR, GAIA_STAR, HYG_STAR, PLACED_BY_GAIA])).toBe('Gaia DR3 3 · HYG 2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { spectralClassification } from '../../shared/astro/spectral';
|
||||
import { temperatureFromColour } from '../../shared/astro/stellar';
|
||||
import { formatDistance, formatLuminosity } from '../../shared/format/quantity';
|
||||
import { StarRecord, SUN_STAR_ID } from '../../shared/models/star.model';
|
||||
import { StarSurface } from '../body-detail/body-view-model';
|
||||
import { HudReadout } from '../hud/hud-dock.component';
|
||||
|
||||
/**
|
||||
* The catalogue that describes a star — its name, type and photometry — as the readout names it.
|
||||
*
|
||||
* Not the same as `source`, which records whose *position* the star has: 62 097 stars HYG
|
||||
* describes sit where Gaia places them, and carry `gaia`. What gives them away is their V
|
||||
* magnitude, which only HYG and the archive measure and the archive's stars have their own source.
|
||||
*/
|
||||
export function describingCatalogue(star: StarRecord): string {
|
||||
if (star.source === 'exoplanet-archive') {
|
||||
return 'NASA Exoplanet Archive';
|
||||
}
|
||||
return star.source === 'gaia' && star.magnitudeBand !== 'V' ? 'Gaia DR3' : 'HYG';
|
||||
}
|
||||
|
||||
/**
|
||||
* What the readout says a star is: the catalogue's classification, or — for the 83 % of stars
|
||||
* that have none, every Gaia star among them — the dwarf type its colour matches, marked as an
|
||||
* estimate. Empty with neither, rather than the ETL's literal "Unknown". Where the colour was itself
|
||||
* read off the archive's temperature, the estimate says so: 30 archive hosts measured in no colour
|
||||
* read "~X, from colour", PSR J1719-1438, a pulsar the archive gives 4 500 K, "~K5, from colour".
|
||||
*/
|
||||
export function starSubtitle(star: StarRecord): string {
|
||||
const classification = spectralClassification(star);
|
||||
const basis = star.colorFromTemperature ? ', from its temperature' : ', from colour';
|
||||
return !classification ? '' : `Spectral type ${classification}${classification.startsWith('~') ? basis : ''}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A star's measured readouts, each with what it was measured in: the band of its magnitude, which
|
||||
* colour its colour index is, the distance's uncertainty, and the catalogues they come from.
|
||||
* The luminosity and radius are the archive's where it publishes them, and otherwise derived and
|
||||
* marked so; a derived radius also says from what.
|
||||
*/
|
||||
export function starReadouts(star: StarRecord, surface?: StarSurface): HudReadout[] {
|
||||
const distancePc = Math.hypot(star.x, star.y, star.z);
|
||||
const catalogue = describingCatalogue(star);
|
||||
return [
|
||||
// Suppressed for the Sun rather than printed as `0.00 pc`, which is arithmetically right
|
||||
// and reads as a bug: the distance from here to here is not a measurement.
|
||||
...(distancePc > 0 ? [{ label: 'Distance', value: formatDistance(distancePc, star.distanceError, star.source === 'exoplanet-archive') }] : []),
|
||||
// A G magnitude and a V one are not comparable: a red dwarf is up to three brighter in G.
|
||||
{ label: 'Magnitude', value: star.magnitudeBand ? `${star.magnitudeBand} ${star.magnitude.toFixed(2)}` : 'Not measured' },
|
||||
...(star.colorIndex !== null
|
||||
? [
|
||||
{
|
||||
label: 'Colour',
|
||||
value: `${star.colorSystem === 'BP-RP' ? 'BP−RP' : 'B−V'} ${star.colorIndex.toFixed(2)}${star.colorFromTemperature ? ', from its temperature' : ''}`,
|
||||
...(star.colorFromTemperature ? { derived: true } : {})
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...(surface?.luminositySolar
|
||||
? [{ label: 'Luminosity', value: formatLuminosity(surface.luminositySolar), ...(surface.luminosityDerived ? { derived: true } : {}) }]
|
||||
: []),
|
||||
...(surface?.radiusSolar ? [radiusReadout(surface.radiusSolar, surface.radiusDerived, radiusBasis(star))] : []),
|
||||
{ label: 'Source', value: catalogue === 'HYG' && star.distanceFromGaia ? 'HYG, Gaia DR3 distance' : catalogue }
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* What a derived radius is worked out from besides the brightness: the temperature the star's
|
||||
* colour gives, or its type's — a giant's always, and a dwarf's with no colour the table reads.
|
||||
* 10 953 radii read "from colour" whose temperature no colour went into: 10 713 giants with one,
|
||||
* 214 stars with none, GJ 3655 (M8) among them, and 26 dwarfs whose colour is off the table.
|
||||
*/
|
||||
function radiusBasis(star: StarRecord): string {
|
||||
if (star.colorFromTemperature) {
|
||||
return 'its temperature';
|
||||
}
|
||||
return temperatureFromColour(star) ? 'colour' : 'its type';
|
||||
}
|
||||
|
||||
/** Two figures for a derived radius, three for a published one: 0.105 is not what colour gives. */
|
||||
function radiusReadout(radiusSolar: number, derived: boolean, basis: string): HudReadout {
|
||||
const digits = derived ? 2 : 3;
|
||||
const figure = radiusSolar.toLocaleString('en-GB', { minimumSignificantDigits: digits, maximumSignificantDigits: digits });
|
||||
return derived
|
||||
? { label: 'Radius', value: `~${figure} solar radii, from ${basis} and brightness`, derived: true }
|
||||
: { label: 'Radius', value: `${figure} solar radii` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the neighbourhood's positions come from: parallaxes, except for the stars only the
|
||||
* Exoplanet Archive places, which sit at its own distances — a lensing model's for the
|
||||
* microlensing hosts among them, OGLE-2005-BLG-390L's 6.6 kpc for one, with no parallax behind it —
|
||||
* and the HYG stars neither Hipparcos nor Gaia measured, which sit at the Gliese catalogue's. Those
|
||||
* have no published error, and of the 313 on the map before 63 were folded into the Gaia source SIMBAD names
|
||||
* them as (250 now), about 154 had a photometric or spectroscopic parallax in CNS3 (Gliese &
|
||||
* Jahreiss 1991), none measured: GJ 3522 at 4.46 pc is 1000/224 mas.
|
||||
*/
|
||||
export function positionsNote(stars: readonly StarRecord[]): string {
|
||||
const archive = stars.filter((star) => star.source === 'exoplanet-archive').length;
|
||||
const gliese = stars.filter((star) => star.source === 'hyg' && star.distanceError === undefined && star.id !== SUN_STAR_ID).length;
|
||||
const where = [
|
||||
'Positions from measured parallaxes',
|
||||
...(gliese === 0 ? [] : [`for the ${gliese.toLocaleString('en-GB')} stars only the Gliese catalogue places, from its distances, about half of them photometric`]),
|
||||
...(archive === 0 ? [] : [`for the ${archive.toLocaleString('en-GB')} planet hosts only the NASA Exoplanet Archive places, from its distances`])
|
||||
].join('; ');
|
||||
return `${where}. Grid marks the galactic plane through the Sun.`;
|
||||
}
|
||||
|
||||
/** What the catalogue holds, counted by the catalogue describing each star, largest first. */
|
||||
export function catalogueCensus(stars: readonly StarRecord[]): string {
|
||||
const counts = new Map<string, number>();
|
||||
for (const star of stars) {
|
||||
const catalogue = describingCatalogue(star);
|
||||
counts.set(catalogue, (counts.get(catalogue) ?? 0) + 1);
|
||||
}
|
||||
return [...counts]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([catalogue, count]) => `${catalogue} ${count.toLocaleString('en-GB')}`)
|
||||
.join(' · ');
|
||||
}
|
||||
@@ -4,8 +4,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import { eclipticToEquatorial, OBLIQUITY_J2000_DEG } from '../../shared/astro/coordinates';
|
||||
import {
|
||||
bodyMarkerRadiusAu,
|
||||
DEFAULT_STAR_MARKER_RADIUS_AU,
|
||||
starMarkerRadiusAu,
|
||||
closestApproachAu,
|
||||
systemFrameRadiusAu,
|
||||
systemFramingDistanceAu,
|
||||
systemGridRingsAu,
|
||||
@@ -23,39 +22,6 @@ const SOLAR_TO_ERIS = { innermost: 0.387, outermost: 67.93 };
|
||||
/** Eris's aphelion, a(1 + e) = 67.934 x 1.4382: past the 80 AU ring its semi-major axis gives. */
|
||||
const ERIS_APHELION_AU = 97.7;
|
||||
|
||||
describe('starMarkerRadiusAu', () => {
|
||||
it('never reaches the innermost orbit', () => {
|
||||
for (const { innermost } of [TRAPPIST_1, GL_357, SOLAR]) {
|
||||
expect(starMarkerRadiusAu(innermost)).toBeLessThan(innermost);
|
||||
}
|
||||
});
|
||||
|
||||
it('shrinks to fit a compact system whose orbits were all inside the old fixed radius', () => {
|
||||
// Every TRAPPIST-1 orbit is inside 0.2 AU, so the star used to swallow the entire system.
|
||||
expect(starMarkerRadiusAu(TRAPPIST_1.innermost)).toBeLessThan(TRAPPIST_1.outermost);
|
||||
expect(starMarkerRadiusAu(GL_357.innermost)).toBeLessThan(GL_357.outermost);
|
||||
});
|
||||
|
||||
it('never grows beyond the default, however wide the system', () => {
|
||||
expect(starMarkerRadiusAu(SOLAR.innermost)).toBeLessThanOrEqual(DEFAULT_STAR_MARKER_RADIUS_AU);
|
||||
expect(starMarkerRadiusAu(500)).toBe(DEFAULT_STAR_MARKER_RADIUS_AU);
|
||||
});
|
||||
|
||||
it('scales in proportion to the innermost orbit', () => {
|
||||
expect(starMarkerRadiusAu(0.02) / starMarkerRadiusAu(0.01)).toBeCloseTo(2, 9);
|
||||
});
|
||||
|
||||
it('falls back to the default when there are no planets to scale against', () => {
|
||||
for (const innermost of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
|
||||
expect(starMarkerRadiusAu(innermost)).toBe(DEFAULT_STAR_MARKER_RADIUS_AU);
|
||||
}
|
||||
});
|
||||
|
||||
it('stays positive for an extremely tight orbit', () => {
|
||||
expect(starMarkerRadiusAu(0.0001)).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('systemFramingDistanceAu', () => {
|
||||
it('fits the radius it is given in view, with room around it', () => {
|
||||
for (const radius of [TRAPPIST_1.outermost, GL_357.outermost, SOLAR.outermost]) {
|
||||
@@ -92,6 +58,41 @@ describe('systemFramingDistanceAu', () => {
|
||||
expect(systemFramingDistanceAu(1, { fovDegrees: 50, aspect: 2.5 })).toBeCloseTo(square, 9);
|
||||
});
|
||||
|
||||
it('backs off to hold a giant wider than its system, and leaves a dwarf to the system', () => {
|
||||
// Betelgeuse drawn at 584 solar radii, 2.7 AU, with nothing around it; the Sun inside its own.
|
||||
const betelgeuseAu = 2.72;
|
||||
expect(systemFrameRadiusAu(systemFramingDistanceAu(0, undefined, betelgeuseAu))).toBeGreaterThan(betelgeuseAu);
|
||||
expect(systemFrameRadiusAu(systemFramingDistanceAu(0.5, undefined, betelgeuseAu))).toBeGreaterThan(betelgeuseAu);
|
||||
expect(systemFramingDistanceAu(SOLAR.outermost, undefined, 0.00465)).toBe(systemFramingDistanceAu(SOLAR.outermost));
|
||||
expect(systemFramingDistanceAu(0, undefined, 0.00465)).toBe(systemFramingDistanceAu(0));
|
||||
});
|
||||
|
||||
it("keeps a giant's disc clear of its neighbours' names on a phone, which hang a fixed 78 px in from their ring", () => {
|
||||
// Betelgeuse at 390x844 and at 1600x1000. Kept to half the half-side, its disc was 98 px in radius
|
||||
// on the phone, where the names come within 66 px of the centre.
|
||||
const betelgeuseAu = 2.72;
|
||||
for (const [width, height] of [[390, 844], [1600, 1000]]) {
|
||||
const viewport = { fovDegrees: 50, aspect: width / height, shorterSidePx: Math.min(width, height) };
|
||||
const distance = systemFramingDistanceAu(0, viewport, betelgeuseAu);
|
||||
const tight = Math.tan((25 * Math.PI) / 180) * Math.min(1, viewport.aspect);
|
||||
const halfSidePx = viewport.shorterSidePx / 2;
|
||||
const discPx = (Math.tan(Math.asin(betelgeuseAu / distance)) / tight) * halfSidePx;
|
||||
expect(discPx).toBeLessThan(0.74 * halfSidePx - 78);
|
||||
expect(discPx).toBeGreaterThan(0.2 * halfSidePx);
|
||||
}
|
||||
});
|
||||
|
||||
it("holds a giant's disc inside the ring its neighbours are named on, and the camera clear of its closest approach", () => {
|
||||
// The ring is at 0.74 of the tighter half-extent; the disc is kept to half of it, in either window.
|
||||
const betelgeuseAu = 2.72;
|
||||
for (const viewport of [{ fovDegrees: 50, aspect: 1.6 }, { fovDegrees: 50, aspect: 0.6 }]) {
|
||||
const distance = systemFramingDistanceAu(0, viewport, betelgeuseAu);
|
||||
const tight = Math.tan((25 * Math.PI) / 180) * Math.min(1, viewport.aspect);
|
||||
expect(Math.tan(Math.asin(betelgeuseAu / distance)) / tight).toBeCloseTo(0.5, 9);
|
||||
expect(distance).toBeGreaterThan(closestApproachAu(betelgeuseAu));
|
||||
}
|
||||
});
|
||||
|
||||
it('caps the distance so a far-flung companion cannot shrink the star to nothing', () => {
|
||||
expect(systemFramingDistanceAu(1000)).toBe(systemFramingDistanceAu(5000));
|
||||
});
|
||||
@@ -171,25 +172,10 @@ describe('the grid and the framing together', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('star and framing together', () => {
|
||||
it('gives compact and wide systems a comparable apparent star size', () => {
|
||||
// Both scale with the system, so the star subtends a similar angle either way — the point
|
||||
// of deriving them from the same measurements rather than fixing them.
|
||||
const apparent = ({ innermost, outermost }: { innermost: number; outermost: number }) =>
|
||||
starMarkerRadiusAu(innermost) / systemFramingDistanceAu(outermost);
|
||||
|
||||
const compact = apparent(TRAPPIST_1);
|
||||
const midRange = apparent(GL_357);
|
||||
|
||||
expect(compact).toBeGreaterThan(0);
|
||||
expect(compact / midRange).toBeGreaterThan(0.25);
|
||||
expect(compact / midRange).toBeLessThan(4);
|
||||
});
|
||||
|
||||
it('always leaves the innermost orbit outside the star, at every scale', () => {
|
||||
for (const innermost of [0.005, 0.01, 0.05, 0.2, 1, 5, 40]) {
|
||||
expect(starMarkerRadiusAu(innermost)).toBeLessThan(innermost);
|
||||
}
|
||||
describe('closestApproachAu', () => {
|
||||
it('keeps the camera three radii out from a giant, and at the old floor for the Sun', () => {
|
||||
expect(closestApproachAu(0.00465)).toBe(0.05);
|
||||
expect(closestApproachAu(2.72)).toBeCloseTo(8.16, 9);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -12,21 +12,11 @@ import { CartesianCoordinates } from '../../shared/astro/coordinates';
|
||||
* the star marker, so they rendered as a lone sphere with nothing around it, and 52% were
|
||||
* framed from a distance floor far larger than the system itself.
|
||||
*
|
||||
* Both quantities are therefore derived from the system's own scale. Because the star and the
|
||||
* camera scale together, a compact system ends up looking like a wide one: same apparent star,
|
||||
* same apparent spread of orbits.
|
||||
* The camera's distance is therefore derived from the system's own scale. The star is not: it is
|
||||
* drawn at its own radius, like every body here, and the framing only makes room for it when the
|
||||
* star is a giant wider than its system.
|
||||
*/
|
||||
|
||||
/** Star size when there are no orbits to scale against, and the ceiling everywhere else. */
|
||||
export const DEFAULT_STAR_MARKER_RADIUS_AU = 0.2;
|
||||
|
||||
/**
|
||||
* Star radius as a fraction of the innermost orbit. Comfortably below 1 so there is visible
|
||||
* space between the star's limb and the closest orbit, rather than the orbit grazing or
|
||||
* disappearing inside it.
|
||||
*/
|
||||
const STAR_RADIUS_TO_INNERMOST_ORBIT = 0.45;
|
||||
|
||||
/**
|
||||
* Clear space left around the framed radius, as a fraction of it. The camera backs off this
|
||||
* much further than the geometry strictly needs, so the outermost ring sits inside the frame
|
||||
@@ -43,6 +33,8 @@ const FRAME_MARGIN = 0.12;
|
||||
export interface SystemViewport {
|
||||
fovDegrees: number;
|
||||
aspect: number;
|
||||
/** The canvas's shorter side in CSS pixels, which a giant's disc is kept clear of its neighbours' names on. */
|
||||
shorterSidePx?: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_SYSTEM_VIEWPORT: SystemViewport = { fovDegrees: 50, aspect: 1 };
|
||||
@@ -73,6 +65,38 @@ const MIN_FRAMING_DISTANCE_AU = 0.06;
|
||||
*/
|
||||
const MAX_FRAMING_DISTANCE_AU = 600;
|
||||
|
||||
/**
|
||||
* How much of the view's tighter half-extent a giant's disc may take on arrival: inside the ring
|
||||
* the system view names the star's neighbours on, and clear of the names hung inward from it,
|
||||
* whose nearest corners come within 292 px of the centre on a 1 000 px view. Framed to fill the
|
||||
* frame instead, Betelgeuse settled at the three-radius closest approach with a disc 379 px in
|
||||
* radius, past the ring 370 px out, and its neighbours' names on it.
|
||||
*/
|
||||
const STAR_FRAME_FRACTION = 0.5;
|
||||
/**
|
||||
* The ring the system view names a star's neighbours on, as a fraction of the frame's shorter
|
||||
* half-side (see `ringPlacement`). Clear of the scale rail at the top and the dock at the bottom.
|
||||
*/
|
||||
export const NEIGHBOUR_RING_FRACTION = 0.74;
|
||||
/**
|
||||
* How far a neighbour's name reaches in from that ring, in pixels whatever the window: its nearest
|
||||
* corner measured 74 px in on a 390 px phone and 78 px on a 1 000 px view, and a margin on that.
|
||||
* The fraction above left the names 0.24 of the half-side, 47 px on a phone, and at 390x844
|
||||
* Betelgeuse's disc, 98 px in radius, had HD 39374's name 70 px from its centre.
|
||||
*/
|
||||
const NAME_REACH_PX = 90;
|
||||
/** However small the window, the disc still takes this much of it. */
|
||||
const MIN_STAR_FRAME_FRACTION = 0.1;
|
||||
|
||||
/** The fraction of the tighter half-extent a giant's disc may take in this viewport. */
|
||||
function starFrameFraction(viewport: SystemViewport): number {
|
||||
if (!viewport.shorterSidePx) {
|
||||
return STAR_FRAME_FRACTION;
|
||||
}
|
||||
const clear = NEIGHBOUR_RING_FRACTION - NAME_REACH_PX / (viewport.shorterSidePx / 2);
|
||||
return Math.min(STAR_FRAME_FRACTION, Math.max(MIN_STAR_FRAME_FRACTION, clear));
|
||||
}
|
||||
|
||||
/** Framing for a star with no known planets, where there is nothing to fit. */
|
||||
const EMPTY_SYSTEM_FRAMING_DISTANCE_AU = 3;
|
||||
|
||||
@@ -107,20 +131,6 @@ export function systemViewDirection(referenceFrame: THREE.Quaternion): THREE.Vec
|
||||
return new THREE.Vector3(x, y, z).normalize().applyQuaternion(referenceFrame);
|
||||
}
|
||||
|
||||
/**
|
||||
* Radius (AU) to draw the system's star at, given its innermost orbit.
|
||||
*
|
||||
* Never larger than {@link DEFAULT_STAR_MARKER_RADIUS_AU}, and never large enough to reach the
|
||||
* closest orbit. Falls back to that default when the system has no planets, since there is
|
||||
* then nothing for the star to crowd.
|
||||
*/
|
||||
export function starMarkerRadiusAu(innermostOrbitAu: number): number {
|
||||
if (!Number.isFinite(innermostOrbitAu) || innermostOrbitAu <= 0) {
|
||||
return DEFAULT_STAR_MARKER_RADIUS_AU;
|
||||
}
|
||||
return Math.min(DEFAULT_STAR_MARKER_RADIUS_AU, innermostOrbitAu * STAR_RADIUS_TO_INNERMOST_ORBIT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Radius, in AU, that the camera can see at the star's own distance — the half-height of the
|
||||
* view frustum where the system sits, along whichever screen axis is tighter.
|
||||
@@ -143,14 +153,33 @@ export function systemFrameRadiusAu(distanceAu: number, viewport: SystemViewport
|
||||
* every semi-major axis by construction, or an eccentric orbit's aphelion where that runs past the
|
||||
* ring, as Eris's does.
|
||||
*/
|
||||
export function systemFramingDistanceAu(framedRadiusAu: number, viewport: SystemViewport = DEFAULT_SYSTEM_VIEWPORT): number {
|
||||
export function systemFramingDistanceAu(framedRadiusAu: number, viewport: SystemViewport = DEFAULT_SYSTEM_VIEWPORT, starRadiusAu = 0): number {
|
||||
// A giant drawn at its own radius can be wider than the system around it — Betelgeuse's 584
|
||||
// solar radii are 2.7 AU — or than the empty framing. Its disc is a sphere's, whose silhouette
|
||||
// from d subtends asin(R / d): the distance that makes it the fraction above of the view.
|
||||
const star = starRadiusAu * Math.sqrt(1 + 1 / (starFrameFraction(viewport) * tightHalfExtent(viewport)) ** 2);
|
||||
if (!Number.isFinite(framedRadiusAu) || framedRadiusAu <= 0) {
|
||||
return EMPTY_SYSTEM_FRAMING_DISTANCE_AU;
|
||||
return Math.max(EMPTY_SYSTEM_FRAMING_DISTANCE_AU, star);
|
||||
}
|
||||
const required = (framedRadiusAu * (1 + FRAME_MARGIN)) / tightHalfExtent(viewport);
|
||||
const required = Math.max((framedRadiusAu * (1 + FRAME_MARGIN)) / tightHalfExtent(viewport), star);
|
||||
return clamp(required, MIN_FRAMING_DISTANCE_AU, MAX_FRAMING_DISTANCE_AU);
|
||||
}
|
||||
|
||||
/** How close the camera may come to the star's centre, whatever the star: ten solar radii. */
|
||||
const MIN_APPROACH_AU = 0.05;
|
||||
/** From three radii out a star spans 39 degrees, most of the view's 50, and the camera stays out of it. */
|
||||
const STAR_CLEARANCE_RADII = 3;
|
||||
|
||||
/**
|
||||
* The orbit controls' minimum distance in a system. The fixed 0.05 AU it used to be leaves the
|
||||
* Sun 11 degrees across; but 23 211 stars on the map are drawn wider than 3.6 solar radii, which
|
||||
* puts 0.05 AU inside three of their radii, and a giant's surface further out still — a zoom
|
||||
* would have carried the camera through it.
|
||||
*/
|
||||
export function closestApproachAu(starRadiusAu: number): number {
|
||||
return Math.max(MIN_APPROACH_AU, STAR_CLEARANCE_RADII * starRadiusAu);
|
||||
}
|
||||
|
||||
/** Roughly how many rings the system grid aims for, and how far past the outermost orbit it runs. */
|
||||
const TARGET_GRID_RING_COUNT = 8;
|
||||
const GRID_EXTENT_TO_OUTERMOST_ORBIT = 1.15;
|
||||
@@ -197,13 +226,8 @@ const KM_PER_AU = 149597870.7;
|
||||
const DEFAULT_BODY_RADIUS_KM = 6371;
|
||||
|
||||
/**
|
||||
* The Sun's own radius, in AU — the one star whose size this map knows.
|
||||
*
|
||||
* Every other star is drawn at {@link starMarkerRadiusAu}, a size derived from its innermost
|
||||
* orbit rather than measured, because no stellar radius reaches the app: the catalogue carries
|
||||
* positions, magnitudes and colours. Gaia publishes `radius_gspphot` for most of what is drawn
|
||||
* here, and until the ETL fetches it, a system's star is the one body in the view that is not
|
||||
* to scale.
|
||||
* The Sun's own radius, in AU: the unit every star's radius is drawn in, the archive's or the one
|
||||
* `starSurfaceOf` derives from its colour and brightness.
|
||||
*/
|
||||
export const SUN_RADIUS_AU = 696340 / KM_PER_AU;
|
||||
|
||||
|
||||
@@ -83,6 +83,16 @@ describe('SystemObjectCardComponent', () => {
|
||||
expect(render(bare).textContent).not.toContain('Measured');
|
||||
});
|
||||
|
||||
it('wraps a long designation rather than cutting off the digits that tell it apart', () => {
|
||||
const host = render({ ...earth, name: '2MASS J21252752-8138278 b', hostStarName: '2MASS J21252752-8138278' });
|
||||
const name = host.querySelector('[data-testid="object-card-name"]')!;
|
||||
expect(name.textContent?.trim()).toBe('2MASS J21252752-8138278 b');
|
||||
for (const line of [name, name.nextElementSibling!]) {
|
||||
expect(line.classList).not.toContain('truncate');
|
||||
expect(line.classList).toContain('wrap-break-word');
|
||||
}
|
||||
});
|
||||
|
||||
it('says a photographed surface is a photograph', () => {
|
||||
expect(render(earth).textContent).toContain('photography');
|
||||
});
|
||||
@@ -98,7 +108,9 @@ describe('SystemObjectCardComponent', () => {
|
||||
appearance: appearance({ equilibriumTemperatureK: null }),
|
||||
});
|
||||
expect(block(host, 'Derived')).not.toContain('Equilibrium temp.');
|
||||
expect(host.textContent).toContain('host star is not in the catalogue');
|
||||
// Not that the host is missing, which it is for 27 of the 2 714 planets without a temperature.
|
||||
expect(host.textContent).toContain('its star’s luminosity or its orbit’s size is not known');
|
||||
expect(host.textContent).not.toContain('not in the catalogue');
|
||||
});
|
||||
|
||||
it('emits rather than navigating, so the scene decides what selection means', () => {
|
||||
|
||||
@@ -35,8 +35,10 @@ import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component';
|
||||
<div data-testid="object-card" class="hud-brackets hud-acquire hud-surface font-body text-text">
|
||||
<div class="flex items-start justify-between gap-3 px-4 pt-4 pb-3">
|
||||
<header class="min-w-0">
|
||||
<p class="truncate text-lg leading-tight font-bold tracking-[0.04em] text-text uppercase">{{ body().name }}</p>
|
||||
<p class="type-eyebrow mt-1 truncate text-accent">{{ readouts().kindLabel }} · {{ body().hostStarName }}</p>
|
||||
<!-- Wrapped, not truncated, as on the detail page: a designation's last digits are the
|
||||
ones that tell it from its neighbours. -->
|
||||
<p data-testid="object-card-name" class="text-lg leading-tight font-bold tracking-[0.04em] wrap-break-word text-text uppercase">{{ body().name }}</p>
|
||||
<p class="type-eyebrow mt-1 wrap-break-word text-accent">{{ readouts().kindLabel }} · {{ body().hostStarName }}</p>
|
||||
</header>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -602,6 +602,32 @@ describe('exoplanet size without a measured radius', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("SystemOrbitsRenderer's star light", () => {
|
||||
const lightOf = (renderer: SystemOrbitsRenderer): THREE.PointLight => {
|
||||
let light: THREE.PointLight | undefined;
|
||||
renderer.object.traverse((object) => (light ??= (object as THREE.PointLight).isPointLight ? (object as THREE.PointLight) : undefined));
|
||||
return light!;
|
||||
};
|
||||
|
||||
it("is white at π from the Sun, or from a star with no temperature", () => {
|
||||
for (const renderer of [new SystemOrbitsRenderer([], [], undefined, 1, 5772), new SystemOrbitsRenderer([], [exoplanet()])]) {
|
||||
expect(lightOf(renderer).color.toArray()).toEqual([1, 1, 1]);
|
||||
expect(lightOf(renderer).intensity).toBe(Math.PI);
|
||||
renderer.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("lights an M dwarf's planets orange-red, at the same π", () => {
|
||||
const renderer = new SystemOrbitsRenderer([], [exoplanet()], undefined, 5.5e-4, 2566);
|
||||
const [r, g, b] = lightOf(renderer).color.toArray();
|
||||
expect(r).toBe(1);
|
||||
expect(g).toBeLessThan(0.5);
|
||||
expect(b).toBeLessThan(0.15);
|
||||
expect(lightOf(renderer).intensity).toBe(Math.PI);
|
||||
renderer.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe('solar-system bodies against Horizons', () => {
|
||||
// The records the app ships, read from bodies.json with their IAU rotational elements, and
|
||||
// Horizons' own positions for them (ICRF, AU; heliocentric for the planets, planet-centred for the
|
||||
|
||||
@@ -7,6 +7,7 @@ import { bodyTexturePath, loadCachedTexture, saturnRing } from '../../shared/ren
|
||||
import { isPropagatableOrbit, keplerRates, meanElementsAt, orbitEllipsePoints, positionAtEpoch, resolveGravitationalParameter, resolveOrbitalElements } from '../../shared/astro/kepler';
|
||||
import { CartesianCoordinates, OBLIQUITY_J2000_DEG } from '../../shared/astro/coordinates';
|
||||
import { tdbFromUtc } from '../../shared/astro/constants';
|
||||
import { blackbodyColor, SOLAR_EFFECTIVE_TEMPERATURE_K } from '../../shared/astro/stellar';
|
||||
import { BodyRecord, MeanElementRates, OrbitalElements, RotationalElements } from '../../shared/models/body.model';
|
||||
import { bodyOrientation, poleFrame } from '../../shared/rendering/body-orientation';
|
||||
import { bodyMarkerRadiusAu, systemGridRingsAu } from './system-framing';
|
||||
@@ -258,12 +259,18 @@ function buildMarker(
|
||||
* floor makes for size. What the light does carry truthfully is which side is day: every body
|
||||
* shows its lit face toward the star, and the terminator falls where it really falls.
|
||||
*
|
||||
* White, at π: a Lambertian surface returns intensity / π of its texture where the light falls
|
||||
* square on it, so π gives back the photograph itself at the point facing the star, and less
|
||||
* towards the limb. A warm tint or a smaller figure darkened the photographs below what they are.
|
||||
* At π: a Lambertian surface returns intensity / π of its texture where the light falls square on
|
||||
* it, so π gives back the photograph itself at the point facing the star, and less towards the
|
||||
* limb. A smaller figure darkened the photographs below what they are.
|
||||
*
|
||||
* In the star's own colour, against the Sun's: the photographs were taken in sunlight, so the
|
||||
* Sun's light is white and gives them back as they are, and another star's shifts them as its
|
||||
* spectrum differs from the Sun's — a 2 566 K M dwarf's is (1, 0.44, 0.10), orange-red, and a
|
||||
* 9 600 K A star's (0.52, 0.67, 1), blue. A star with no temperature is lit as the Sun.
|
||||
*/
|
||||
function starLight(): THREE.PointLight {
|
||||
function starLight(temperatureK: number | null | undefined): THREE.PointLight {
|
||||
const light = new THREE.PointLight(0xffffff, Math.PI, 0, 0);
|
||||
light.color.setRGB(...blackbodyColor(temperatureK ?? SOLAR_EFFECTIVE_TEMPERATURE_K, SOLAR_EFFECTIVE_TEMPERATURE_K), THREE.LinearSRGBColorSpace);
|
||||
light.position.set(0, 0, 0);
|
||||
return light;
|
||||
}
|
||||
@@ -364,8 +371,6 @@ export class SystemOrbitsRenderer {
|
||||
readonly members: readonly SystemMember[];
|
||||
/** Largest semi-major axis (AU) among top-level bodies/exoplanets; 0 if there are none. */
|
||||
readonly maxTopLevelSemiMajorAxisAu: number;
|
||||
/** Smallest semi-major axis (AU) among top-level bodies/exoplanets; 0 if there are none. */
|
||||
readonly minTopLevelSemiMajorAxisAu: number;
|
||||
/**
|
||||
* The plane this system is read against, as a rotation from XY into the scene's equatorial
|
||||
* frame: the ecliptic for the solar system, the plane of the sky for everything else.
|
||||
@@ -416,7 +421,9 @@ export class SystemOrbitsRenderer {
|
||||
* system is and therefore what it looks like. Omitted for a host that is not in the star
|
||||
* catalogue, leaving its bodies classified on size and density alone.
|
||||
*/
|
||||
hostLuminositySolar?: number | null
|
||||
hostLuminositySolar?: number | null,
|
||||
/** The host star's effective temperature, which is the colour of the light it casts. */
|
||||
hostTemperatureK?: number | null
|
||||
) {
|
||||
const members: SystemMember[] = [];
|
||||
const topLevelBodiesById = new Map<string, BodyRecord>();
|
||||
@@ -427,7 +434,6 @@ export class SystemOrbitsRenderer {
|
||||
].filter(({ axis }) => Number.isFinite(axis) && axis > 0);
|
||||
const topLevelAxes = topLevelOrbits.map(({ axis }) => axis);
|
||||
this.maxTopLevelSemiMajorAxisAu = topLevelAxes.length > 0 ? Math.max(...topLevelAxes) : 0;
|
||||
this.minTopLevelSemiMajorAxisAu = topLevelAxes.length > 0 ? Math.min(...topLevelAxes) : 0;
|
||||
|
||||
for (const body of bodies) {
|
||||
if (!body.parentBodyId) {
|
||||
@@ -525,7 +531,7 @@ export class SystemOrbitsRenderer {
|
||||
}
|
||||
// The star lights its own system. The star marker itself is unlit — it is the source, not a
|
||||
// surface — so nothing here changes how it is drawn.
|
||||
this.object.add(starLight());
|
||||
this.object.add(starLight(hostTemperatureK));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -81,6 +81,11 @@ describe('rankSearchResults', () => {
|
||||
expect(rank(entries, 'Proxima')[0]).toBe('Proxima Centauri');
|
||||
});
|
||||
|
||||
it("lists a host's own planets ahead of the systems whose names run on from its own", () => {
|
||||
const entries = [star('K2-18'), star('K2-180'), star('K2-181'), exoplanet('K2-18 b'), exoplanet('K2-18 c')];
|
||||
expect(rank(entries, 'K2-18')).toEqual(['K2-18', 'K2-18 b', 'K2-18 c', 'K2-180', 'K2-181']);
|
||||
});
|
||||
|
||||
it('breaks remaining ties by name length, then alphabetically', () => {
|
||||
const entries = [exoplanet('Kepler-1292 b'), exoplanet('Kepler-9 c'), exoplanet('Kepler-9 b'), exoplanet('Kepler-15 b')];
|
||||
expect(rank(entries, 'Kepler')).toEqual(['Kepler-9 b', 'Kepler-9 c', 'Kepler-15 b', 'Kepler-1292 b']);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { spectralClassification } from '../../shared/astro/spectral';
|
||||
|
||||
export type SearchResultKind = 'star' | 'body' | 'exoplanet';
|
||||
|
||||
export interface SearchEntry {
|
||||
@@ -6,15 +8,33 @@ export interface SearchEntry {
|
||||
subtitle: string;
|
||||
/** HYG star id, for `kind: 'star'` results. */
|
||||
starId?: number;
|
||||
/**
|
||||
* The star behind a `kind: 'star'` entry, classified by {@link entrySubtitle} for the rows shown
|
||||
* only. Classified up front, all 455 571 took 140-230 ms of the main thread at boot, for the
|
||||
* route index, and again each time the search tab was opened.
|
||||
*/
|
||||
star?: Parameters<typeof spectralClassification>[0];
|
||||
/** `bodies.json`/`exoplanets.json` id, for `kind: 'body' | 'exoplanet'` results. */
|
||||
bodyId?: string;
|
||||
}
|
||||
|
||||
/** The line an entry is listed with: its subtitle, or a star's classification. */
|
||||
export function entrySubtitle(entry: SearchEntry): string {
|
||||
return entry.star ? spectralClassification(entry.star) : entry.subtitle;
|
||||
}
|
||||
|
||||
/**
|
||||
* How well a name matches, best first. The gaps are what matter: any exact match outranks every
|
||||
* prefix match, and so on, so a better kind of match can never be crowded out by a worse one.
|
||||
*/
|
||||
const MATCH_EXACT = 4;
|
||||
/**
|
||||
* A prefix that ends where a word does, "kepler-186 f" for "kepler-186", ahead of one running on into
|
||||
* the same word, "kepler-1860". Level with it, the star outranked the planet on kind, and once the
|
||||
* archive's hosts became stars, searching a host's name listed K2-180 to K2-186 and none of K2-18's
|
||||
* planets: 325 hosts lost some of their own planets from the eight rows shown, and now 50 do.
|
||||
*/
|
||||
const MATCH_WORD_PREFIX = 3.5;
|
||||
const MATCH_PREFIX = 3;
|
||||
const MATCH_WORD_START = 2;
|
||||
const MATCH_SUBSTRING = 1;
|
||||
@@ -76,7 +96,7 @@ function scoreIndexed(indexed: IndexedSearchEntry, normalizedQuery: string, comp
|
||||
return MATCH_EXACT;
|
||||
}
|
||||
if (indexed.normalizedName.startsWith(normalizedQuery)) {
|
||||
return MATCH_PREFIX;
|
||||
return WORD_SEPARATORS.test(indexed.normalizedName.charAt(normalizedQuery.length)) ? MATCH_WORD_PREFIX : MATCH_PREFIX;
|
||||
}
|
||||
if (indexed.words.some((word) => word.startsWith(normalizedQuery))) {
|
||||
return MATCH_WORD_START;
|
||||
|
||||
@@ -16,7 +16,10 @@ function starRecord(id: number, name: string): StarRecord {
|
||||
/** Enough "Iot ..." stars to fill the result list ahead of the moon Io, as the real index does. */
|
||||
const STARS: StarRecord[] = [
|
||||
...Array.from({ length: 12 }, (_, i) => starRecord(100 + i, `Iot Star ${i}`)),
|
||||
starRecord(1, 'Proxima Centauri')
|
||||
starRecord(1, 'Proxima Centauri'),
|
||||
// As the ETL files a Gaia star: no type, a BP−RP colour; and a star with neither.
|
||||
{ ...starRecord(2, 'TRAPPIST-1'), spectralType: 'Unknown', colorIndex: 4.902, colorSystem: 'BP-RP' },
|
||||
{ ...starRecord(3, 'KMT-2016-BLG-1107L'), spectralType: 'Unknown', colorIndex: null }
|
||||
];
|
||||
|
||||
const IO: BodyRecord = {
|
||||
@@ -111,6 +114,17 @@ describe('SearchComponent', () => {
|
||||
expect(resultNames()).toContain('Proxima Cen b');
|
||||
});
|
||||
|
||||
it("lists a star by the type its colour gives it where it has none, and never as \"Unknown\"", async () => {
|
||||
const kindLine = (): string => (element.querySelector('[data-testid="search-results"] button span:last-child')?.textContent ?? '').trim();
|
||||
await type('TRAPPIST-1');
|
||||
expect(kindLine()).toBe('Star · ~M8');
|
||||
await type('KMT-2016-BLG-1107L');
|
||||
expect(kindLine()).toBe('Star');
|
||||
// Classified for the rows shown only: over all 455 571 stars, each opening of the tab spent 140-230 ms on it.
|
||||
const index = (fixture.componentInstance as unknown as { index(): { entry: { kind: string; subtitle: string } }[] }).index();
|
||||
expect(index.filter(({ entry }) => entry.kind === 'star').every(({ entry }) => entry.subtitle === '')).toBe(true);
|
||||
});
|
||||
|
||||
it('shows nothing for a query that matches nothing', async () => {
|
||||
await type('zzzzz');
|
||||
expect(element.querySelector('[data-testid="search-results"]')).toBeNull();
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Router } from '@angular/router';
|
||||
import { DataLoaderService } from '../../core/data/data-loader.service';
|
||||
import { NavigationStore } from '../../shared/state/navigation.store';
|
||||
import { ReticleIconComponent } from '../../shared/ui/reticle-icon.component';
|
||||
import { buildSearchIndex, IndexedSearchEntry, rankSearchResults, SearchEntry, SearchResultKind } from './search-ranking';
|
||||
import { buildSearchIndex, entrySubtitle, IndexedSearchEntry, rankSearchResults, SearchEntry, SearchResultKind } from './search-ranking';
|
||||
|
||||
const MAX_RESULTS = 8;
|
||||
const MIN_QUERY_LENGTH = 2;
|
||||
@@ -66,7 +66,7 @@ const KIND_LABELS: Record<SearchResultKind, string> = {
|
||||
class="flex w-full items-baseline gap-3 border-l border-transparent px-3 py-2 text-left transition-colors hover:border-l-accent hover:bg-accent/8 focus-visible:border-l-accent focus-visible:bg-accent/12 focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate text-sm text-text">{{ result.name }}</span>
|
||||
<span class="type-label max-w-[45%] shrink-0 truncate text-muted">{{ kindLabel(result.kind) }} · {{ result.subtitle }}</span>
|
||||
<span class="type-label max-w-[45%] shrink-0 truncate text-muted">{{ kindLabel(result.kind) }}@if (result.subtitle) { · {{ result.subtitle }}}</span>
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
@@ -107,7 +107,7 @@ export class SearchComponent {
|
||||
});
|
||||
|
||||
readonly matchTotal = computed(() => this.matches().length);
|
||||
readonly results = computed(() => this.matches().slice(0, MAX_RESULTS));
|
||||
readonly results = computed(() => this.matches().slice(0, MAX_RESULTS).map((entry) => ({ ...entry, subtitle: entrySubtitle(entry) })));
|
||||
|
||||
constructor(
|
||||
private readonly dataLoader: DataLoaderService,
|
||||
@@ -153,7 +153,7 @@ export class SearchComponent {
|
||||
]);
|
||||
|
||||
const entries: SearchEntry[] = [
|
||||
...stars.map((star): SearchEntry => ({ kind: 'star', name: star.name, subtitle: star.spectralType, starId: star.id })),
|
||||
...stars.map((star): SearchEntry => ({ kind: 'star', name: star.name, subtitle: '', star, starId: star.id })),
|
||||
...bodies.map((body): SearchEntry => ({ kind: 'body', name: body.name, subtitle: body.kind, bodyId: body.id })),
|
||||
...exoplanets.map((exoplanet): SearchEntry => ({ kind: 'exoplanet', name: exoplanet.name, subtitle: exoplanet.hostStarName, bodyId: exoplanet.id }))
|
||||
];
|
||||
|
||||
@@ -18,7 +18,7 @@ describe('classifyOpenNgcType', () => {
|
||||
});
|
||||
|
||||
it('groups nebulae, remnants and cluster-with-nebulosity as nebulae', () => {
|
||||
for (const type of ['PN', 'HII', 'EmN', 'RfN', 'Neb', 'DrkN', 'SNR', 'Cl+N']) {
|
||||
for (const type of ['PN', 'HII', 'EmN', 'RfN', 'Neb', 'SNR', 'Cl+N']) {
|
||||
expect(classifyOpenNgcType(type)).toBe('nebula');
|
||||
}
|
||||
});
|
||||
@@ -36,6 +36,10 @@ describe('classifyOpenNgcType', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves out a dark nebula, which a sprite that adds light cannot draw', () => {
|
||||
expect(classifyOpenNgcType('DrkN')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects missing or unknown types', () => {
|
||||
for (const type of ['', ' ', 'wat', undefined, null]) {
|
||||
expect(classifyOpenNgcType(type)).toBeNull();
|
||||
|
||||
@@ -38,7 +38,10 @@ const MAX_PARALLAX_DISTANCE_PC = 100000;
|
||||
/**
|
||||
* OpenNGC object-type codes grouped into the three kinds the backdrop distinguishes.
|
||||
* Codes not listed here (`Dup` duplicates, `NonEx` non-existent entries, plain stars `*`,
|
||||
* doubles `**`, `Nova`, `Other`) are not deep-sky objects and are dropped.
|
||||
* doubles `**`, `Nova`, `Other`) are not deep-sky objects and are dropped. Nor is `DrkN`, a dark
|
||||
* nebula, drawn: it is dust in front of the light behind it, and the backdrop's sprites can only
|
||||
* add light — the Coalsack and the Horsehead, which OpenNGC's addendum brought in, glowed pink
|
||||
* where the sky has a hole.
|
||||
*/
|
||||
const KIND_BY_OPENNGC_TYPE: Readonly<Record<string, DeepSkyKind>> = {
|
||||
// Galaxies, and multi-galaxy systems.
|
||||
@@ -52,7 +55,6 @@ const KIND_BY_OPENNGC_TYPE: Readonly<Record<string, DeepSkyKind>> = {
|
||||
EmN: 'nebula',
|
||||
RfN: 'nebula',
|
||||
Neb: 'nebula',
|
||||
DrkN: 'nebula',
|
||||
SNR: 'nebula',
|
||||
'Cl+N': 'nebula',
|
||||
// Star clusters and associations.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildStarNameIndex, normalizeStarName, resolveHostStarId } from './host-star-matching';
|
||||
import { ARCHIVE_EPOCH, ARCHIVE_ID_BASE, archiveDistancePc, archiveStarId, buildStarNameIndex, CATALOGUE_EPOCH, normalizeStarName, resolveHostStarId } from './host-star-matching';
|
||||
import { propagateProperMotion, raDegDecDistanceToXyz } from './coordinates';
|
||||
import { StarRecord } from '../models/star.model';
|
||||
|
||||
@@ -81,13 +81,13 @@ describe('resolveHostStarId', () => {
|
||||
expect(id).toBe(80);
|
||||
});
|
||||
|
||||
// GJ 15 A's archive row sits at J2016, 46″ along its proper motion from Groombridge 34's
|
||||
// GJ 15 A's archive row sits at J2015.5, 45″ along its proper motion from Groombridge 34's
|
||||
// J2000 place — and only 16″ from an unrelated Gaia entry. Nearest-to-the-published-point
|
||||
// picks the interloper; carrying the query back the sixteen years must put the planets on
|
||||
// the star that actually moved there.
|
||||
// picks the interloper; carrying the query back the fifteen and a half years must put the
|
||||
// planets on the star that actually moved there.
|
||||
it('picks the star the proper motion says the query is, not the entry nearest the published point', () => {
|
||||
const primary = star(90, 'Groombridge 34', 4.595364, 44.022955, 3.562);
|
||||
const published = propagateProperMotion(4.595364, 44.022955, 2891.5, 411.9, 16);
|
||||
const published = propagateProperMotion(4.595364, 44.022955, 2891.5, 411.9, 15.5);
|
||||
const interloper = star(91, 'Gaia DR3 385334196532776576', published.raDeg, published.decDeg + 16 / 3600, 3.563);
|
||||
|
||||
const id = resolveHostStarId(
|
||||
@@ -109,6 +109,17 @@ describe('resolveHostStarId', () => {
|
||||
expect(id).toBeNull();
|
||||
});
|
||||
|
||||
// The archive's 5.92 pc is TICv8's; its own parallax, 263.26 mas, says 3.80 pc, which is the
|
||||
// star's. With the parallax given, the same query is Luyten's Star — and a parallax that
|
||||
// contradicts the star as well rescues nothing.
|
||||
it("accepts the archive's parallax where its sy_dist contradicts the star", () => {
|
||||
const luytens = star(100, "Luyten's Star", 111.8496, 5.2258, 3.79);
|
||||
const query = { hostname: 'GJ 273', raDeg: 111.8496, decDeg: 5.2258, distancePc: 5.921535 };
|
||||
|
||||
expect(resolveHostStarId({ ...query, parallaxMas: 263.26 }, [luytens])).toBe(100);
|
||||
expect(resolveHostStarId({ ...query, parallaxMas: 168.9 }, [luytens])).toBeNull();
|
||||
});
|
||||
|
||||
// The tolerance is transverse — parsecs on the sky, not an angle — so the same 15″ offset
|
||||
// is a match at 50 pc and a stranger at 200 pc.
|
||||
it('scales the angular tolerance with the host distance', () => {
|
||||
@@ -196,6 +207,14 @@ describe('resolveHostStarId', () => {
|
||||
expect(id).toBe(1);
|
||||
});
|
||||
|
||||
it("takes the archive's parallax where it gives no distance, and nothing from a parallax that is none", () => {
|
||||
// mu2 Sco: sy_dist blank, sy_plx 6.31 mas; the catalogue has Pipirima at 145.3 pc.
|
||||
expect(archiveDistancePc(undefined, 6.31)).toBeCloseTo(158.48, 2);
|
||||
expect(archiveDistancePc(145.35, 6.31)).toBe(145.35);
|
||||
expect(archiveDistancePc(undefined, 0)).toBeNaN();
|
||||
expect(archiveDistancePc(undefined, undefined)).toBeNaN();
|
||||
});
|
||||
|
||||
it('lets a named host resolve even with no usable distance', () => {
|
||||
const id = resolveHostStarId({ hostname: 'Sirius', raDeg: 101.3, decDeg: -16.7, distancePc: 0 }, FIXTURE_STARS);
|
||||
|
||||
@@ -203,3 +222,44 @@ describe('resolveHostStarId', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('the archive epoch', () => {
|
||||
it("carries Barnard's star from the archive's position to where Gaia DR3's goes, to a few milliarcseconds", () => {
|
||||
// The archive publishes Gaia DR2's J2015.5 position and motion; DR3's is at J2016.
|
||||
const archive = propagateProperMotion(269.4486144, 4.7379808, -802.803, 10362.5, CATALOGUE_EPOCH - ARCHIVE_EPOCH);
|
||||
const dr3 = propagateProperMotion(269.44850252543836, 4.739420051112412, -801.5509783684709, 10362.394206546573, CATALOGUE_EPOCH - 2016);
|
||||
const [a, b] = [archive, dr3].map(({ raDeg, decDeg }) => raDegDecDistanceToXyz(raDeg, decDeg, 1));
|
||||
const separationArcsec = (Math.acos(Math.min(1, a.x * b.x + a.y * b.y + a.z * b.z)) * 180 * 3600) / Math.PI;
|
||||
expect(separationArcsec).toBeLessThan(0.02);
|
||||
});
|
||||
|
||||
it("matches Barnard's star from the archive's row at J2015.5, not an entry where a J2016 or J2015 row would carry it", () => {
|
||||
// Half a year of its 10.4″/yr is 5.2″: a decoy placed where carrying the row back sixteen, or
|
||||
// fifteen, years lands is nearer that point than Barnard's star is.
|
||||
const row = { hostname: "Barnard's star", raDeg: 269.4486144, decDeg: 4.7379808, distancePc: 1.8266, pmRaMasPerYear: -802.803, pmDecMasPerYear: 10362.5 };
|
||||
const at = (years: number) => propagateProperMotion(row.raDeg, row.decDeg, row.pmRaMasPerYear, row.pmDecMasPerYear, years);
|
||||
const [truth, fromJ2016, fromJ2015] = [at(CATALOGUE_EPOCH - ARCHIVE_EPOCH), at(CATALOGUE_EPOCH - 2016), at(CATALOGUE_EPOCH - 2015)];
|
||||
const stars = [
|
||||
star(201, 'Gaia DR3 decoy', fromJ2016.raDeg, fromJ2016.decDeg, 1.8266),
|
||||
star(202, 'Gaia DR3 other decoy', fromJ2015.raDeg, fromJ2015.decDeg, 1.8266),
|
||||
star(200, 'GJ 699', truth.raDeg, truth.decDeg, 1.8266)
|
||||
];
|
||||
expect(resolveHostStarId(row, stars)).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('archiveStarId', () => {
|
||||
it('gives a host the same id whatever else the archive holds, inside the range left for it', () => {
|
||||
const kepler186 = archiveStarId('Kepler-186', new Set());
|
||||
const others = new Set(['1RXS J160929.1-210524', 'Kepler-1860', 'Kepler-452', 'TOI-700'].map((name) => archiveStarId(name, new Set())));
|
||||
expect(archiveStarId('Kepler-186', others)).toBe(kepler186);
|
||||
expect(others.has(kepler186)).toBe(false);
|
||||
expect(kepler186).toBeGreaterThanOrEqual(ARCHIVE_ID_BASE);
|
||||
expect(kepler186).toBeLessThan(2 ** 30);
|
||||
});
|
||||
|
||||
it('moves a host whose id is taken to the next free one', () => {
|
||||
const kepler186 = archiveStarId('Kepler-186', new Set());
|
||||
expect(archiveStarId('Kepler-186', new Set([kepler186, kepler186 + 1]))).toBe(kepler186 + 2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,30 @@ export interface HostStarQuery {
|
||||
/** μα·cos δ in mas/yr, as the archive publishes it (`sy_pmra`); missing means unknown. */
|
||||
pmRaMasPerYear?: number;
|
||||
pmDecMasPerYear?: number;
|
||||
/**
|
||||
* The archive's parallax in mas (`sy_plx`), a second distance the ratio test accepts. Its
|
||||
* `sy_dist` comes from TICv8 and contradicts its own parallax past the tolerance for 47 of the
|
||||
* 5 959 systems that publish both — Lalande 21185 at 5.68 pc for 392 mas (2.55 pc), Luyten's
|
||||
* Star at 5.92 for 263 mas, Struve 2398 B at 6.84 for 285 — and those three are in the
|
||||
* catalogue, 0.1″ to 9″ from the archive's direction. A second chance rather than a
|
||||
* replacement: past a few hundred parsecs the inverse of a low-S/N parallax is the worse
|
||||
* estimate (K2-238, 538 pc by `sy_dist`, would be 6 779).
|
||||
*/
|
||||
parallaxMas?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A host's distance as the archive gives it: `sy_dist`, or where that is blank, the inverse of its
|
||||
* parallax `sy_plx`; `NaN` with neither. mu2 Sco has no `sy_dist` and a 6.31 mas parallax, which
|
||||
* finds Pipirima (HIP 82545) 0.4″ from the archive's direction; left blank, its planet had no star.
|
||||
*/
|
||||
export function archiveDistancePc(distancePc: number | undefined, parallaxMas: number | undefined): number {
|
||||
return distancePc ?? (parallaxMas !== undefined && parallaxMas > 0 ? 1000 / parallaxMas : Number.NaN);
|
||||
}
|
||||
|
||||
function distancesAgree(a: number, b: number): boolean {
|
||||
const [near, far] = a < b ? [a, b] : [b, a];
|
||||
return (far - near) / near <= MERGE_DISTANCE_RATIO_TOLERANCE;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,15 +89,17 @@ export const HOST_TRANSVERSE_TOLERANCE_PC = 0.01;
|
||||
/**
|
||||
* The archive does not say which epoch a row's position is for, and they are demonstrably
|
||||
* mixed: alf Tau and GJ 273 publish J2000 (the raw position sits under an arcsecond from our
|
||||
* star, and carrying it back doubles the error), HD 133131 and TOI-2459 publish Gaia's J2016
|
||||
* (the carried-back position lands to 0.1″). So every query is tried at both ends — as
|
||||
* published, and carried back sixteen years with the archive's own proper motion — and a star
|
||||
* is judged on whichever is closer. Guessing one epoch picks companions: assume J2016 and
|
||||
* Aldebaran's planet lands on Gl 171.1B, assume J2000 and GJ 15 A's land on a Gaia entry
|
||||
* 15.9″ out.
|
||||
* star, and carrying it back doubles the error), HD 133131 and TOI-2459 publish Gaia DR2's J2015.5
|
||||
* (the carried-back position lands to 0.1″). DR2's, not DR3's J2016, although the archive names
|
||||
* the DR3 source: Barnard's star, Teegarden's Star, TRAPPIST-1 and 66 of the 67 archive-placed
|
||||
* stars moving over 100 mas a year equal their DR2 position to a milliarcsecond and none their
|
||||
* DR3 one. So every query is tried at both ends — as published, and carried back fifteen and a
|
||||
* half years with the archive's own proper motion — and a star is judged on whichever is closer.
|
||||
* Guessing one epoch picks companions: assume the later one and Aldebaran's planet lands on Gl
|
||||
* 171.1B, assume J2000 and GJ 15 A's land on a Gaia entry 15.9″ out.
|
||||
*/
|
||||
const CATALOGUE_EPOCH = 2000.0;
|
||||
const ARCHIVE_LATEST_EPOCH = 2016.0;
|
||||
export const CATALOGUE_EPOCH = 2000.0;
|
||||
export const ARCHIVE_EPOCH = 2015.5;
|
||||
|
||||
function knownMotion(masPerYear: number | undefined): number {
|
||||
return Number.isFinite(masPerYear) ? (masPerYear as number) : 0;
|
||||
@@ -125,10 +151,11 @@ export function resolveHostStarId(
|
||||
// star would pass the direction test and the last one in array order would win.
|
||||
knownMotion(query.pmRaMasPerYear),
|
||||
knownMotion(query.pmDecMasPerYear),
|
||||
CATALOGUE_EPOCH - ARCHIVE_LATEST_EPOCH
|
||||
CATALOGUE_EPOCH - ARCHIVE_EPOCH
|
||||
);
|
||||
const carried = raDegDecDistanceToXyz(carriedBack.raDeg, carriedBack.decDeg, 1);
|
||||
|
||||
const parallaxPc = query.parallaxMas !== undefined && query.parallaxMas > 0 ? 1000 / query.parallaxMas : Number.NaN;
|
||||
const minCosine = Math.cos(Math.min(Math.PI, HOST_TRANSVERSE_TOLERANCE_PC / query.distancePc));
|
||||
let best: StarRecord | null = null;
|
||||
let bestCosine = -2;
|
||||
@@ -146,8 +173,7 @@ export function resolveHostStarId(
|
||||
if (cosine < minCosine || cosine <= bestCosine) {
|
||||
continue;
|
||||
}
|
||||
const [near, far] = query.distancePc < starDistance ? [query.distancePc, starDistance] : [starDistance, query.distancePc];
|
||||
if ((far - near) / near > MERGE_DISTANCE_RATIO_TOLERANCE) {
|
||||
if (!distancesAgree(query.distancePc, starDistance) && !(parallaxPc > 0 && distancesAgree(parallaxPc, starDistance))) {
|
||||
continue;
|
||||
}
|
||||
best = star;
|
||||
@@ -156,3 +182,30 @@ export function resolveHostStarId(
|
||||
|
||||
return best ? best.id : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 — which leaves 3.7 million.
|
||||
*/
|
||||
export const ARCHIVE_ID_BASE = 1_070_000_000;
|
||||
const ARCHIVE_ID_RANGE = 2 ** 30 - ARCHIVE_ID_BASE;
|
||||
|
||||
/**
|
||||
* The id of a star the ETL places from the archive, from its host's name rather than from its
|
||||
* place in the answer. Numbered in pl_name order, a refresh that added or dropped one host
|
||||
* renumbered every host after it — one row dropped renamed 3 276 of 3 277 ids, and a bookmark kept
|
||||
* on Kepler-186 opened Kepler-1860 — while HYG's and Gaia's ids hold. FNV-1a over the name, into
|
||||
* the range above; a name whose id is taken takes the next free one, the one case a refresh can
|
||||
* still move, and only between the two names that collided.
|
||||
*/
|
||||
export function archiveStarId(hostname: string, taken: ReadonlySet<number>): number {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < hostname.length; i++) {
|
||||
hash = Math.imul(hash ^ hostname.charCodeAt(i), 0x01000193) >>> 0;
|
||||
}
|
||||
let offset = hash % ARCHIVE_ID_RANGE;
|
||||
while (taken.has(ARCHIVE_ID_BASE + offset)) {
|
||||
offset = (offset + 1) % ARCHIVE_ID_RANGE;
|
||||
}
|
||||
return ARCHIVE_ID_BASE + offset;
|
||||
}
|
||||
|
||||
@@ -66,6 +66,13 @@ describe('equilibriumTemperatureK', () => {
|
||||
expect(equilibriumTemperatureK(1, 1, 0.8)!).toBeLessThan(equilibriumTemperatureK(1, 1, 0)!);
|
||||
});
|
||||
|
||||
it('never falls below the microwave background, however far the star', () => {
|
||||
// 2MASS J21252752-8138278 b is 7 493 AU out; around a star of a fiftieth of the Sun's output,
|
||||
// starlight alone would hold it at 1.1 K.
|
||||
expect(equilibriumTemperatureK(0.02, 7493)!).toBeGreaterThan(2.7255);
|
||||
expect(equilibriumTemperatureK(0.02, 7493)!).toBeLessThan(3);
|
||||
});
|
||||
|
||||
it('has no answer without a star or an orbit', () => {
|
||||
expect(equilibriumTemperatureK(null, 1)).toBeNull();
|
||||
expect(equilibriumTemperatureK(1, undefined)).toBeNull();
|
||||
|
||||
@@ -88,6 +88,9 @@ export function bulkDensityGramsPerCm3(massEarth: number | undefined, radiusEart
|
||||
return EARTH_DENSITY_G_PER_CM3 * (massEarth / Math.pow(radiusEarth, 3));
|
||||
}
|
||||
|
||||
/** The cosmic microwave background's temperature today (Fixsen 2009, ApJ 707, 916). */
|
||||
const CMB_TEMPERATURE_K = 2.7255;
|
||||
|
||||
/**
|
||||
* Equilibrium temperature in kelvin: the temperature at which a body re-radiates exactly the
|
||||
* starlight it absorbs.
|
||||
@@ -97,6 +100,10 @@ export function bulkDensityGramsPerCm3(massEarth: number | undefined, radiusEart
|
||||
* push the real surface warmer — Venus's surface is 737 K against an equilibrium 232 K. It is
|
||||
* nonetheless the right quantity here, because it is what decides the *state* of the material a
|
||||
* world is made of, which is what its surface looks like.
|
||||
*
|
||||
* The body also absorbs the cosmic microwave background, added as a second source in the same
|
||||
* balance. It is nothing beside any star inside a few hundred AU, and it is why nothing in space is
|
||||
* colder than 2.7 K: by starlight alone, the planet 7 493 AU from 2MASS J21252752-8138278 read 1 K.
|
||||
*/
|
||||
export function equilibriumTemperatureK(
|
||||
luminositySolar: number | null | undefined,
|
||||
@@ -106,7 +113,8 @@ export function equilibriumTemperatureK(
|
||||
if (!luminositySolar || !semiMajorAxisAu || luminositySolar <= 0 || semiMajorAxisAu <= 0) {
|
||||
return null;
|
||||
}
|
||||
return SOLAR_EQUILIBRIUM_TEMPERATURE_K * Math.pow(luminositySolar, 0.25) * Math.pow(semiMajorAxisAu, -0.5) * Math.pow(1 - bondAlbedo, 0.25);
|
||||
const starlit = SOLAR_EQUILIBRIUM_TEMPERATURE_K * Math.pow(luminositySolar, 0.25) * Math.pow(semiMajorAxisAu, -0.5) * Math.pow(1 - bondAlbedo, 0.25);
|
||||
return Math.pow(starlit ** 4 + CMB_TEMPERATURE_K ** 4, 0.25);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { parseSpectralClass, SPECTRAL_CLASSES, spectralTypeToColorIndex } from './spectral';
|
||||
import { dwarfSequenceAtColor, dwarfSequenceAtTemperature, dwarfSequenceAtType, isGiant, parseSpectralClass, spectralClassification, SPECTRAL_CLASSES, spectralTypeFromColor, spectralTypeToColorIndex, temperatureToColorIndex } from './spectral';
|
||||
|
||||
describe('parseSpectralClass', () => {
|
||||
it('reads a clean class and subclass', () => {
|
||||
@@ -46,6 +46,20 @@ describe('parseSpectralClass', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('isGiant', () => {
|
||||
it('reads luminosity classes I to III off the primary, the giant and supergiant prefixes, and carbon and S stars', () => {
|
||||
for (const type of ['M1Ib + B2.5V', 'K5III', 'M2II-IIIvar', 'C7Iab', 'K0IIIb', 'gK0', 'cM2', 'N5', 'Ce+', 'S57:']) {
|
||||
expect(isGiant(type), type).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves dwarfs, subgiants, a dwarf with a giant companion and the unclassified alone', () => {
|
||||
for (const type of ['G2V', 'B2IV', 'F0IVn', 'M5Ve', 'K1V + M3III', 'g-k', 'Unknown', 'DA', '']) {
|
||||
expect(isGiant(type), type).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('spectralTypeToColorIndex', () => {
|
||||
it('places the Sun near its real B-V of 0.65', () => {
|
||||
expect(spectralTypeToColorIndex('G2V')).toBeCloseTo(0.626, 2);
|
||||
@@ -83,3 +97,119 @@ describe('spectralTypeToColorIndex', () => {
|
||||
expect(spectralTypeToColorIndex('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('temperatureToColorIndex', () => {
|
||||
it("puts the Sun's temperature at its own B-V and a cool dwarf where the dwarf sequence has it", () => {
|
||||
expect(temperatureToColorIndex(5772)).toBeCloseTo(0.65, 2);
|
||||
// Two thirds of the way from M2 (3 560 K, B−V 1.505) to M2.5 (3 470 K, 1.522).
|
||||
expect(temperatureToColorIndex(3500)).toBeCloseTo(1.5163, 4);
|
||||
});
|
||||
|
||||
it('reads back as the temperature it came from, so the correction is the one at that temperature', () => {
|
||||
for (const temperatureK of [31400, 12000, 7000, 5772, 4000, 3500, 3157, 2566, 2420]) {
|
||||
expect(dwarfSequenceAtColor(temperatureToColorIndex(temperatureK))!.temperatureK).toBeCloseTo(temperatureK, 6);
|
||||
}
|
||||
});
|
||||
|
||||
it('has no answer outside the table, nor for a temperature that is not one', () => {
|
||||
for (const temperatureK of [580, 2419, 31401, 50000, 0, Number.NaN]) {
|
||||
expect(temperatureToColorIndex(temperatureK)).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('spectralTypeFromColor', () => {
|
||||
it("reads the Sun's type off either colour", () => {
|
||||
expect(spectralTypeFromColor(0.65, 'B-V')).toBe('G2');
|
||||
expect(spectralTypeFromColor(0.82, 'BP-RP')).toBe('G2');
|
||||
});
|
||||
|
||||
it('reads a red dwarf the way it was classified', () => {
|
||||
// TRAPPIST-1 is M8 V, and Gaia has it at BP−RP 4.90; Proxima is M5.5 Ve at B−V 1.81.
|
||||
expect(spectralTypeFromColor(4.902, 'BP-RP')).toBe('M8');
|
||||
expect(spectralTypeFromColor(1.807, 'B-V')).toBe('M5');
|
||||
});
|
||||
|
||||
it('does not read one colour as the other', () => {
|
||||
// 1.43 is a K5 dwarf in BP−RP and an M0 in B−V.
|
||||
expect(spectralTypeFromColor(1.43, 'BP-RP')).toBe('K5');
|
||||
expect(spectralTypeFromColor(1.43, 'B-V')).toBe('M0');
|
||||
expect(spectralTypeFromColor(1.43)).toBe('M0');
|
||||
});
|
||||
|
||||
it('has no answer past either end of the table, nor without a colour', () => {
|
||||
expect(spectralTypeFromColor(-0.35, 'B-V')).toBeNull();
|
||||
expect(spectralTypeFromColor(-0.15, 'BP-RP')).toBeNull();
|
||||
expect(spectralTypeFromColor(5.5, 'BP-RP')).toBeNull();
|
||||
expect(spectralTypeFromColor(null, 'B-V')).toBeNull();
|
||||
expect(spectralTypeFromColor(-0.301, 'B-V')).toBe('B0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('spectralClassification', () => {
|
||||
it("gives the catalogue's type, else the colour's marked as an estimate, else nothing", () => {
|
||||
expect(spectralClassification({ spectralType: 'M5Ve', colorIndex: 1.807, colorSystem: 'B-V' })).toBe('M5Ve');
|
||||
expect(spectralClassification({ spectralType: 'Unknown', colorIndex: 4.902, colorSystem: 'BP-RP' })).toBe('~M8');
|
||||
expect(spectralClassification({ spectralType: 'Unknown', colorIndex: null })).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('dwarfSequenceAtColor', () => {
|
||||
it("puts the Sun's colour in either system at the Sun's temperature and correction", () => {
|
||||
for (const [colour, system] of [[0.65, 'B-V'], [0.823, 'BP-RP']] as const) {
|
||||
const point = dwarfSequenceAtColor(colour, system)!;
|
||||
expect(point.temperatureK).toBeCloseTo(5770, 0);
|
||||
expect(point.bolometricCorrectionV).toBeCloseTo(-0.085, 3);
|
||||
expect(point.gMinusV).toBeCloseTo(-0.165, 3);
|
||||
}
|
||||
});
|
||||
|
||||
it('interpolates between the two types a colour falls between', () => {
|
||||
// Halfway from M1.5 (B−V 1.495, 3 620 K, −1.50) to M2 (1.505, 3 560 K, −1.62).
|
||||
const point = dwarfSequenceAtColor(1.5, 'B-V')!;
|
||||
expect(point.temperatureK).toBeCloseTo(3590, 6);
|
||||
expect(point.bolometricCorrectionV).toBeCloseTo(-1.56, 6);
|
||||
});
|
||||
|
||||
it('has no answer past either end of the table, and no G−V where none is tabulated', () => {
|
||||
expect(dwarfSequenceAtColor(2.2, 'B-V')).toBeNull();
|
||||
expect(dwarfSequenceAtColor(-0.15, 'BP-RP')).toBeNull();
|
||||
expect(dwarfSequenceAtColor(null)).toBeNull();
|
||||
expect(dwarfSequenceAtColor(-0.29, 'B-V')!.gMinusV).toBeNull();
|
||||
});
|
||||
|
||||
it('reads the row at the end a colour is past, when asked to', () => {
|
||||
expect(dwarfSequenceAtColor(2.2, 'B-V', true)).toEqual({ bMinusV: 2.16, temperatureK: 2420, bolometricCorrectionV: -5.78, gMinusV: -3.09 });
|
||||
expect(dwarfSequenceAtColor(5.3, 'BP-RP', true)).toEqual({ bMinusV: 2.16, temperatureK: 2420, bolometricCorrectionV: -5.78, gMinusV: -3.09 });
|
||||
expect(dwarfSequenceAtColor(-0.4, 'B-V', true)).toEqual({ bMinusV: -0.301, temperatureK: 31400, bolometricCorrectionV: -2.99, gMinusV: null });
|
||||
expect(dwarfSequenceAtColor(null, 'B-V', true)).toBeNull();
|
||||
});
|
||||
|
||||
it("reads a white dwarf bluer than BP−RP's end at the temperature measured at its colour, and the table's correction there", () => {
|
||||
// Gentile Fusillo et al. (2021): 15 369 K at −0.15, where B9's row had 10 700; between B6 and B5.
|
||||
const point = dwarfSequenceAtColor(-0.15, 'BP-RP', true)!;
|
||||
expect(point.temperatureK).toBeCloseTo(15369, 6);
|
||||
expect(point.bolometricCorrectionV).toBeCloseTo(-1.13 - 0.21 * (869 / 1200), 6);
|
||||
expect(dwarfSequenceAtColor(-0.13, 'BP-RP', true)!.temperatureK).toBeCloseTo(15369 - 4669 * (2 / 3), 6);
|
||||
expect(dwarfSequenceAtColor(-0.6, 'BP-RP', true)!.temperatureK).toBeCloseTo(28585, 6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dwarfSequenceAtType', () => {
|
||||
it("reads an O type off Mamajek's O rows, which carry no colour, and any other off its own row", () => {
|
||||
expect(dwarfSequenceAtType('O7.5Iab:')).toEqual({ bMinusV: null, temperatureK: 36100, bolometricCorrectionV: -3.33, gMinusV: null });
|
||||
expect(dwarfSequenceAtType('B8Ia')!.temperatureK).toBe(12300);
|
||||
expect(dwarfSequenceAtType('O9.7')!.temperatureK).toBeCloseTo(31900 - 500 * (0.2 / 0.5), 6);
|
||||
expect(dwarfSequenceAtType('M9')!.temperatureK).toBe(2420);
|
||||
expect(dwarfSequenceAtType('Unknown')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('dwarfSequenceAtTemperature', () => {
|
||||
it("is a type's own row at its temperature, between two rows between them, and the end row past either end", () => {
|
||||
expect(dwarfSequenceAtTemperature(5770)).toEqual({ bMinusV: 0.65, temperatureK: 5770, bolometricCorrectionV: -0.085, gMinusV: -0.165 });
|
||||
expect(dwarfSequenceAtTemperature(3615).bolometricCorrectionV).toBeCloseTo(-1.51, 6);
|
||||
expect(dwarfSequenceAtTemperature(50000).temperatureK).toBe(31400);
|
||||
expect(dwarfSequenceAtTemperature(1000).temperatureK).toBe(2420);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,6 +69,20 @@ export function parseSpectralClass(
|
||||
return { spectralClass, subclass };
|
||||
}
|
||||
|
||||
/** Luminosity class I (with Ia, Iab, Ib), II or III, not the I of a IV. */
|
||||
const GIANT_LUMINOSITY_CLASS = /(?<![IV])(?:III|II|I)(?![IV])/;
|
||||
|
||||
/**
|
||||
* Whether a spectral type says its star is a giant or supergiant: luminosity class I to III,
|
||||
* HYG's `g` or `c` prefix, or a carbon or S star (C, N, R, S), which are all giants on the
|
||||
* asymptotic branch whether or not a class is given. Read off the primary only — Antares is
|
||||
* `M1Ib + B2.5V`.
|
||||
*/
|
||||
export function isGiant(spectralType: string | null | undefined): boolean {
|
||||
const primary = (spectralType ?? '').split('+')[0].trim();
|
||||
return /^(?:[gc][OBAFGKM]|[CNRS])/.test(primary) || GIANT_LUMINOSITY_CLASS.test(primary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Approximate B-V colour index for a spectral type, interpolating between the class anchors by
|
||||
* subclass. Returns `null` when no class can be recognised, which is the honest answer for the
|
||||
@@ -87,3 +101,196 @@ export function spectralTypeToColorIndex(spectralType: string | null | undefined
|
||||
|
||||
return from + (to - from) * (subclass / 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* B-V colour index for an effective temperature, for stars the Exoplanet Archive gives a
|
||||
* temperature but no B magnitude: the dwarf sequence below read the other way, interpolated
|
||||
* between the two types the temperature falls between, so that the correction and the temperature
|
||||
* read back off the colour are the table's at that temperature. Ballesteros' blackbody fit, used
|
||||
* before, runs 0.1 to 0.2 redder than the table below 3 800 K, and the colour it gave was read on
|
||||
* the table: 3 500 K came back as 3 102 K with a correction 1.15 magnitudes too large, and the 57
|
||||
* hosts placed this way were off the archive's own luminosity by 0.23 dex at the median. `null`
|
||||
* outside the table, 2 420 to 31 400 K, rather than a colour clamped to its end — CFBDSIR
|
||||
* J145829+101343, a 580 K brown dwarf, read as B−V 2.00 and "~M6".
|
||||
*/
|
||||
export function temperatureToColorIndex(temperatureK: number): number | null {
|
||||
const [hottest, coolest] = [ROWS_WITH_COLOUR[1][0], DWARF_SEQUENCE[DWARF_SEQUENCE.length - 1]];
|
||||
return temperatureK <= hottest[3] && temperatureK >= coolest[3] ? dwarfSequenceAtTemperature(temperatureK).bMinusV : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The mean dwarf sequence: B−V, Gaia BP−RP, effective temperature (K), bolometric correction to V
|
||||
* and Gaia G−V by spectral type, from Pecaut & Mamajek (2013, ApJS 208, 9, table 5) as Mamajek
|
||||
* maintains it online (version 2022.04.16), where the Gaia columns were added. From O3 to M8.5,
|
||||
* past which BP−RP turns back. The O rows, read by type only, carry no colour: B−V stops telling
|
||||
* types apart there, the whole O sequence spanning 0.03 of it. BP−RP starts at B9, the bluest it
|
||||
* is tabulated for, and G−V at B1.5.
|
||||
*/
|
||||
type SequenceRow = readonly [string, number | null, number | null, number, number, number | null];
|
||||
|
||||
const DWARF_SEQUENCE: readonly SequenceRow[] = [
|
||||
['O3', null, null, 44900, -4.01, null], ['O4', null, null, 42900, -3.89, null], ['O5', null, null, 41400, -3.76, null],
|
||||
['O5.5', null, null, 40500, -3.67, null], ['O6', null, null, 39500, -3.57, null], ['O6.5', null, null, 38300, -3.49, null],
|
||||
['O7', null, null, 37100, -3.41, null], ['O7.5', null, null, 36100, -3.33, null], ['O8', null, null, 35100, -3.24, null],
|
||||
['O8.5', null, null, 34300, -3.18, null], ['O9', null, null, 33300, -3.11, null], ['O9.5', null, null, 31900, -3.01, null],
|
||||
['B0', -0.301, null, 31400, -2.99, null], ['B0.5', -0.289, null, 29000, -2.83, null], ['B1', -0.278, null, 26000, -2.58, null],
|
||||
['B1.5', -0.252, null, 24500, -2.44, -0.021], ['B2', -0.215, null, 20600, -2.03, -0.008], ['B2.5', -0.198, null, 18500, -1.77, -0.003],
|
||||
['B3', -0.178, null, 17000, -1.54, 0.001], ['B4', -0.165, null, 16400, -1.49, 0.004], ['B5', -0.156, null, 15700, -1.34, 0.007],
|
||||
['B6', -0.14, null, 14500, -1.13, 0.01], ['B7', -0.128, null, 14000, -1.05, 0.012], ['B8', -0.109, null, 12300, -0.73, 0.016],
|
||||
['B9', -0.07, -0.12, 10700, -0.42, 0.018], ['B9.5', -0.05, -0.087, 10400, -0.36, 0.017], ['A0', 0, -0.037, 9700, -0.21, 0.015],
|
||||
['A1', 0.035, 0.005, 9300, -0.14, 0.01], ['A2', 0.07, 0.068, 8800, -0.07, 0], ['A3', 0.1, 0.11, 8600, -0.04, -0.005],
|
||||
['A4', 0.14, 0.166, 8250, -0.02, -0.01], ['A5', 0.16, 0.194, 8100, 0, -0.015], ['A6', 0.185, 0.222, 7910, 0.005, -0.02],
|
||||
['A7', 0.21, 0.263, 7760, 0.01, -0.03], ['A8', 0.25, 0.32, 7590, 0.02, -0.04], ['A9', 0.27, 0.327, 7400, 0.02, -0.05],
|
||||
['F0', 0.295, 0.377, 7220, 0.01, -0.06], ['F1', 0.33, 0.434, 7020, 0.005, -0.07], ['F2', 0.37, 0.49, 6820, -0.005, -0.08],
|
||||
['F3', 0.39, 0.518, 6750, -0.01, -0.09], ['F4', 0.41, 0.546, 6670, -0.015, -0.1], ['F5', 0.44, 0.587, 6550, -0.02, -0.11],
|
||||
['F6', 0.486, 0.64, 6350, -0.03, -0.13], ['F7', 0.5, 0.67, 6280, -0.035, -0.14], ['F8', 0.53, 0.694, 6180, -0.04, -0.15],
|
||||
['F9', 0.56, 0.719, 6050, -0.05, -0.145], ['F9.5', 0.58, 0.767, 5990, -0.06, -0.155], ['G0', 0.595, 0.784, 5930, -0.065, -0.155],
|
||||
['G1', 0.622, 0.803, 5860, -0.073, -0.158], ['G2', 0.65, 0.823, 5770, -0.085, -0.165], ['G3', 0.66, 0.832, 5720, -0.095, -0.167],
|
||||
['G4', 0.67, 0.841, 5680, -0.1, -0.173], ['G5', 0.68, 0.85, 5660, -0.105, -0.179], ['G6', 0.7, 0.869, 5600, -0.115, -0.186],
|
||||
['G7', 0.71, 0.88, 5550, -0.125, -0.194], ['G8', 0.73, 0.9, 5480, -0.14, -0.202], ['G9', 0.775, 0.95, 5380, -0.16, -0.21],
|
||||
['K0', 0.816, 0.983, 5270, -0.195, -0.227], ['K1', 0.857, 1.01, 5170, -0.23, -0.25], ['K2', 0.884, 1.1, 5100, -0.26, -0.27],
|
||||
['K3', 0.99, 1.21, 4830, -0.375, -0.32], ['K4', 1.09, 1.34, 4600, -0.52, -0.42], ['K5', 1.15, 1.43, 4440, -0.63, -0.44],
|
||||
['K6', 1.24, 1.53, 4300, -0.75, -0.51], ['K7', 1.34, 1.7, 4100, -0.93, -0.58], ['K8', 1.363, 1.73, 3990, -1.03, -0.625],
|
||||
['K9', 1.4, 1.79, 3930, -1.07, -0.66], ['M0', 1.42, 1.84, 3850, -1.15, -0.7], ['M0.5', 1.445, 1.97, 3770, -1.29, -0.76],
|
||||
['M1', 1.485, 2.09, 3660, -1.42, -0.82], ['M1.5', 1.495, 2.13, 3620, -1.5, -0.87], ['M2', 1.505, 2.23, 3560, -1.62, -0.925],
|
||||
['M2.5', 1.522, 2.39, 3470, -1.78, -1.02], ['M3', 1.53, 2.5, 3430, -1.93, -1.1], ['M3.5', 1.6, 2.78, 3270, -2.28, -1.28],
|
||||
['M4', 1.65, 2.94, 3210, -2.51, -1.4], ['M4.5', 1.69, 3.16, 3110, -2.84, -1.54], ['M5', 1.83, 3.35, 3060, -3.11, -1.7],
|
||||
['M5.5', 1.94, 3.71, 2930, -3.58, -1.95], ['M6', 2.01, 4.16, 2810, -4.13, -2.37], ['M6.5', 2.07, 4.5, 2740, -4.62, -2.7],
|
||||
['M7', 2.12, 4.65, 2680, -4.99, -2.98], ['M7.5', 2.14, 4.72, 2630, -5.32, -3.15], ['M8', 2.15, 4.86, 2570, -5.65, -3.11],
|
||||
['M8.5', 2.16, 5.1, 2420, -5.78, -3.09]
|
||||
];
|
||||
|
||||
/**
|
||||
* The rows each colour is tabulated for, filtered once. Filtered on every call, the search index
|
||||
* and the star field's tints, which read the table for each of the 455 571 stars, spent 140-230
|
||||
* ms of the main thread on it at boot, and the search index again on each opening of its tab.
|
||||
*/
|
||||
const ROWS_WITH_COLOUR = { 1: DWARF_SEQUENCE.filter((row) => row[1] !== null), 2: DWARF_SEQUENCE.filter((row) => row[2] !== null) } as const;
|
||||
|
||||
/**
|
||||
* The spectral type of the dwarf whose colour is nearest, for the stars no catalogue classified —
|
||||
* every Gaia star, 83 % of the map. An estimate, and the caller must say so: it assumes a dwarf,
|
||||
* so a giant is given a later type than its own — Pollux, a K0 giant at B−V 0.99, reads as K3 —
|
||||
* and it ignores reddening, which makes a star behind dust look later still. `null` for a
|
||||
* colour outside the table, rather than the nearest end of it.
|
||||
*/
|
||||
export function spectralTypeFromColor(colorIndex: number | null, system: 'B-V' | 'BP-RP' = 'B-V'): string | null {
|
||||
const column = system === 'B-V' ? 1 : 2;
|
||||
const rows = ROWS_WITH_COLOUR[column];
|
||||
if (colorIndex === null || !(colorIndex >= rows[0][column]! && colorIndex <= rows[rows.length - 1][column]!)) {
|
||||
return null;
|
||||
}
|
||||
let nearest = rows[0];
|
||||
for (const row of rows) {
|
||||
if (Math.abs(row[column]! - colorIndex) < Math.abs(nearest[column]! - colorIndex)) {
|
||||
nearest = row;
|
||||
}
|
||||
}
|
||||
return nearest[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* A star's classification as a row or an option lists it: the catalogue's type, or the dwarf type
|
||||
* its colour matches, marked `~` as an estimate; empty with neither, rather than the ETL's literal
|
||||
* "Unknown", which 383 695 stars carry and search rows and route options used to print.
|
||||
*/
|
||||
export function spectralClassification(star: { spectralType: string; colorIndex: number | null; colorSystem?: 'B-V' | 'BP-RP' }): string {
|
||||
if (star.spectralType && star.spectralType !== 'Unknown') {
|
||||
return star.spectralType;
|
||||
}
|
||||
const estimate = spectralTypeFromColor(star.colorIndex, star.colorSystem);
|
||||
return estimate ? `~${estimate}` : '';
|
||||
}
|
||||
|
||||
/** What the dwarf sequence says of a star of a given colour. */
|
||||
export interface DwarfSequencePoint {
|
||||
/** B−V, the colour in the other system's terms where it was read off BP−RP; `null` among the O rows. */
|
||||
bMinusV: number | null;
|
||||
temperatureK: number;
|
||||
/** Bolometric correction to V: what V leaves out of the star's total output, in magnitudes. */
|
||||
bolometricCorrectionV: number;
|
||||
/** Gaia G − Johnson V; `null` bluer than B1.5, where it is not tabulated. */
|
||||
gMinusV: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The dwarf sequence read at a colour, interpolated between the two types it falls between — the
|
||||
* same table the spectral estimate reads, so a star's temperature and its estimated type agree.
|
||||
* Linear rather than nearest, because the red end is steep: B−V runs 1.495 to 1.53 from M1.5 to
|
||||
* M3, over which the temperature drops 190 K and the correction 0.4 magnitudes. `null` outside
|
||||
* the table, as for the estimate — or, with `clampToTable`, the row at the end the colour is past,
|
||||
* except bluer than BP−RP's end, where it is read off {@link WHITE_DWARF_BP_RP}.
|
||||
*/
|
||||
export function dwarfSequenceAtColor(colorIndex: number | null, system: 'B-V' | 'BP-RP' = 'B-V', clampToTable = false): DwarfSequencePoint | null {
|
||||
const column = system === 'B-V' ? 1 : 2;
|
||||
const rows = ROWS_WITH_COLOUR[column];
|
||||
const [bluest, reddest] = [rows[0][column]!, rows[rows.length - 1][column]!];
|
||||
if (colorIndex === null || !Number.isFinite(colorIndex) || (!clampToTable && !(colorIndex >= bluest && colorIndex <= reddest))) {
|
||||
return null;
|
||||
}
|
||||
if (system === 'BP-RP' && colorIndex < bluest) {
|
||||
const next = Math.max(1, WHITE_DWARF_BP_RP.findIndex(([colour]) => colour >= colorIndex));
|
||||
const [[blueColour, blueK], [redColour, redK]] = [WHITE_DWARF_BP_RP[next - 1], WHITE_DWARF_BP_RP[next]];
|
||||
return dwarfSequenceAtTemperature(blueK + (redK - blueK) * Math.max((colorIndex - blueColour) / (redColour - blueColour), 0));
|
||||
}
|
||||
return sequenceWhere(rows, (row) => row[column]!, Math.min(Math.max(colorIndex, bluest), reddest));
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective temperature by BP−RP past the blue end of the table, which is B9's −0.12: the median
|
||||
* pure-hydrogen temperature Gentile Fusillo et al. (2021, MNRAS 508, 3877) fit, in bins of ±0.025,
|
||||
* to the 104 of the map's stars there that their white dwarf catalogue has, all white dwarfs; with
|
||||
* the table's end, 10 700 K, and their bluest bin held past it. Every one of them was drawn at B9's
|
||||
* 10 700 K where they measure 14 266 to 39 304, and at a median 1.53 times the radius their mass
|
||||
* and gravity give.
|
||||
*/
|
||||
const WHITE_DWARF_BP_RP: readonly (readonly [number, number])[] = [
|
||||
[-0.4, 28585], [-0.35, 24521], [-0.3, 22090], [-0.25, 19012], [-0.2, 17079], [-0.15, 15369], [-0.12, 10700]
|
||||
];
|
||||
|
||||
/**
|
||||
* The dwarf sequence at a spectral type, between the two rows it falls between, O3 to M8.5; the end
|
||||
* row past either end. `null` for a type with no class the parser reads.
|
||||
*/
|
||||
export function dwarfSequenceAtType(spectralType: string | null | undefined): DwarfSequencePoint | null {
|
||||
const parsed = parseSpectralClass(spectralType);
|
||||
return parsed && sequenceWhere(DWARF_SEQUENCE, (row) => TYPE_INDEX.get(row)!, typeIndex(parsed));
|
||||
}
|
||||
|
||||
/** A type as a number rising down the table: ten to a class, O0 at 0. */
|
||||
function typeIndex({ spectralClass, subclass }: { spectralClass: SpectralClass; subclass: number }): number {
|
||||
return SPECTRAL_CLASSES.indexOf(spectralClass) * 10 + subclass;
|
||||
}
|
||||
|
||||
const TYPE_INDEX = new Map(DWARF_SEQUENCE.map((row) => [row, typeIndex(parseSpectralClass(row[0])!)]));
|
||||
|
||||
/** The dwarf sequence at an effective temperature, between the two types it falls between; the end row past either end. */
|
||||
export function dwarfSequenceAtTemperature(temperatureK: number): DwarfSequencePoint {
|
||||
return sequenceWhere(ROWS_WITH_COLOUR[1], (row) => -row[3], -temperatureK);
|
||||
}
|
||||
|
||||
/** The sequence where `key`, rising down `rows`, reaches `value`: linear between the two rows either side, the end row past either end. */
|
||||
function sequenceWhere(rows: readonly SequenceRow[], key: (row: SequenceRow) => number, value: number): DwarfSequencePoint {
|
||||
// Bisected, not scanned: the star field reads it for each of the 455 571 stars, and a scan of the
|
||||
// rows took 200 ms of that.
|
||||
let low = 1;
|
||||
let high = rows.length - 1;
|
||||
while (low < high) {
|
||||
const middle = (low + high) >> 1;
|
||||
if (key(rows[middle]) >= value) {
|
||||
high = middle;
|
||||
} else {
|
||||
low = middle + 1;
|
||||
}
|
||||
}
|
||||
const first = rows[low - 1];
|
||||
const second = rows[low];
|
||||
const t = Math.min(Math.max((value - key(first)) / (key(second) - key(first)), 0), 1);
|
||||
const lerp = (from: number, to: number): number => from + (to - from) * t;
|
||||
return {
|
||||
bMinusV: first[1] === null || second[1] === null ? null : lerp(first[1], second[1]),
|
||||
temperatureK: lerp(first[3], second[3]),
|
||||
bolometricCorrectionV: lerp(first[4], second[4]),
|
||||
gMinusV: first[5] === null || second[5] === null ? null : lerp(first[5], second[5])
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { raDegDecDistanceToXyz } from './coordinates';
|
||||
import { StarRecord } from '../models/star.model';
|
||||
import { directionCosine, isSameStar, MERGE_ANGULAR_TOLERANCE_DEG, mergeStarCatalogues, placementDistancePc } from './star-merge';
|
||||
import { directionCosine, foldByIdentity, hipparcosDistancePc, HYG_UNKNOWN_DISTANCE_PC, isSameStar, MERGE_ANGULAR_TOLERANCE_DEG, mergeStarCatalogues, NAKED_EYE_MAGNITUDE, placementDistancePc } from './star-merge';
|
||||
|
||||
/** A star at a given sky position and distance, which is how catalogues actually report them. */
|
||||
function at(id: number, raDeg: number, decDeg: number, distancePc: number, overrides: Partial<StarRecord> = {}): StarRecord {
|
||||
@@ -79,6 +79,40 @@ describe('isSameStar', () => {
|
||||
expect(isSameStar(companion, at(43109, 131.69 + arcsecOfRa(2.7, 6.42), 6.42, 40, { name: 'Ashlesha', magnitude: 3.38 }))).toBe(false);
|
||||
});
|
||||
|
||||
// GJ 1035 and GJ 3052 as HYG has them from Gliese, against their Gaia entries: 21″ away at
|
||||
// about the same distance, and 6.6″ away at half Gaia's distance. Their motions agree to 2 and 3 %.
|
||||
it('matches a Gliese entry to the Gaia entry moving with it, a minute of arc away or at another distance', () => {
|
||||
const gj1035 = at(1, 19.9245, 84.1612, 14.4, { magnitude: 13.1, source: 'gaia', pmRaMasYr: -981.9, pmDecMasYr: 475.6 });
|
||||
const gliese1035 = at(118058, 19.9245 + arcsecOfRa(21.2, 84.1612), 84.1612, 13.7, { name: 'GJ 1035', magnitude: 14.77, pmRaMasYr: -978.0, pmDecMasYr: 458.1 });
|
||||
expect(isSameStar(gj1035, gliese1035)).toBe(true);
|
||||
expect(isSameStar(gj1035, { ...gliese1035, pmRaMasYr: undefined, pmDecMasYr: undefined })).toBe(false);
|
||||
|
||||
const gj3052 = at(2, 11.0897, 9.1262, 25.0, { magnitude: 12.6, source: 'gaia', pmRaMasYr: 813.1, pmDecMasYr: -2.6 });
|
||||
const gliese3052 = at(118014, 11.0897 + arcsecOfRa(6.6, 9.1262), 9.1262, 12.3, { name: 'GJ 3052', magnitude: 13.8, pmRaMasYr: 799.7, pmDecMasYr: -20.9 });
|
||||
expect(isSameStar(gj3052, gliese3052)).toBe(true);
|
||||
});
|
||||
|
||||
it("matches LHS 288's Gliese entry to its Gaia entry, a minute and a half away", () => {
|
||||
const lhs288 = at(1000388933, 161.08846863703002, -61.20979834516761, 4.8316, { magnitude: 11.859, source: 'gaia', pmRaMasYr: -346.21, pmDecMasYr: 1611.1 });
|
||||
const gliese3618 = at(118704, 161.13112906780827, -61.1935811389294, 4.4883, { name: 'GJ 3618', magnitude: 13.92, pmRaMasYr: -340.24, pmDecMasYr: 1614.54 });
|
||||
expect(Math.acos(directionCosine(lhs288, gliese3618)) * (180 / Math.PI) * 3600).toBeCloseTo(94, 0);
|
||||
expect(isSameStar(lhs288, gliese3618)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not match two entries moving differently past fifteen arcseconds, nor co-moving ones past 160″', () => {
|
||||
const kept = at(1, 120, 30, 10, { magnitude: 12, source: 'gaia', pmRaMasYr: 1000, pmDecMasYr: 0 });
|
||||
expect(isSameStar(kept, at(2, 120 + arcsecOfRa(20, 30), 30, 10, { magnitude: 13, pmRaMasYr: 750, pmDecMasYr: 0 }))).toBe(false);
|
||||
expect(isSameStar(kept, at(2, 120 + arcsecOfRa(20, 30), 30, 10, { magnitude: 13, pmRaMasYr: 850, pmDecMasYr: 0 }))).toBe(true);
|
||||
expect(isSameStar(kept, at(2, 120 + arcsecOfRa(155, 30), 30, 10, { magnitude: 13, pmRaMasYr: 1000, pmDecMasYr: 0 }))).toBe(true);
|
||||
expect(isSameStar(kept, at(2, 120 + arcsecOfRa(165, 30), 30, 10, { magnitude: 13, pmRaMasYr: 1000, pmDecMasYr: 0 }))).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps a co-moving primary out of its companion's entry", () => {
|
||||
// A binary shares its motion, so only the brightness tells GJ 9160 from its companion 13.5″ away.
|
||||
const companion = at(1, 69.54, -14.3, 24.4, { magnitude: 15.1, source: 'gaia', pmRaMasYr: -78.5, pmDecMasYr: -150.8 });
|
||||
expect(isSameStar(companion, at(2, 69.54 + arcsecOfRa(13.5, -14.3), -14.3, 24.4, { magnitude: 7.3, pmRaMasYr: -78.5, pmDecMasYr: -150.8 }))).toBe(false);
|
||||
});
|
||||
|
||||
it('matches on direction rather than on 3D proximity', () => {
|
||||
// The distinction the merge rests on. These two are 60 pc apart in space and are the same
|
||||
// star; a 3D-proximity test would have to be so loose it swallowed real neighbours.
|
||||
@@ -131,6 +165,52 @@ describe('mergeStarCatalogues', () => {
|
||||
expect(summary.duplicates).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps the distance's error with the distance, and the photometry with the description", () => {
|
||||
// Proxima's parallax is 768.07 ± 0.05 mas in Gaia and 768.13 ± 1.04 in Hipparcos: the star is
|
||||
// drawn at Gaia's, so the error it is drawn with is Gaia's, while V 11.01 and B−V 1.81 stay HYG's.
|
||||
const hyg = at(70666, 217.4289, -62.6795, 1.2959, {
|
||||
name: 'Proxima Centauri',
|
||||
magnitude: 11.01,
|
||||
magnitudeBand: 'V',
|
||||
colorIndex: 1.807,
|
||||
colorSystem: 'B-V',
|
||||
distanceError: 0.00135,
|
||||
distanceFromGaia: false
|
||||
});
|
||||
const gaia = at(1000064182, 217.4289, -62.6795, 1.302, {
|
||||
name: 'Gaia DR3 5853498713190525696',
|
||||
magnitude: 8.985,
|
||||
magnitudeBand: 'G',
|
||||
colorIndex: 3.805,
|
||||
colorSystem: 'BP-RP',
|
||||
distanceError: 0.000065,
|
||||
distanceFromGaia: true,
|
||||
source: 'gaia'
|
||||
});
|
||||
const [merged] = mergeStarCatalogues([{ ...HIPPARCOS, stars: [hyg] }, { ...GAIA, stars: [gaia] }]).stars;
|
||||
|
||||
expect(merged).toMatchObject({ magnitude: 11.01, magnitudeBand: 'V', colorIndex: 1.807, colorSystem: 'B-V', distanceError: 0.000065, distanceFromGaia: true });
|
||||
});
|
||||
|
||||
it("keeps Gaia's colour where the description has none", () => {
|
||||
// HD 45951: HYG gives V 6.20 and K2III but no B−V; Gaia DR3 3369454521490604416 has BP−RP 1.248.
|
||||
const hyg = at(119622, 97.79164, 16.93863, 112, { name: 'HD 45951', magnitude: 6.2, magnitudeBand: 'V', spectralType: 'K2III', colorIndex: null });
|
||||
const gaia = at(1000004369, 97.79164, 16.93863, 112, { name: 'Gaia DR3 3369454521490604416', magnitude: 5.898, magnitudeBand: 'G', colorIndex: 1.248, colorSystem: 'BP-RP', source: 'gaia' });
|
||||
const [merged] = mergeStarCatalogues([{ ...HIPPARCOS, stars: [hyg] }, { ...GAIA, stars: [gaia] }]).stars;
|
||||
|
||||
expect(merged).toMatchObject({ name: 'HD 45951', magnitude: 6.2, magnitudeBand: 'V', colorIndex: 1.248, colorSystem: 'BP-RP' });
|
||||
});
|
||||
|
||||
it("takes a Hipparcos distance more precise than the Gaia entry it folds into, along Gaia's direction", () => {
|
||||
// Schedar: 71.0 pc ±3.5 % in Gaia, which saturates on it, and 70.0 pc ±1.0 % in Hipparcos.
|
||||
const hyg = at(3179, 10.1268, 56.5373, 70.0, { name: 'Schedar', magnitude: 2.24, distanceError: 0.0105, distanceFromGaia: false });
|
||||
const gaia = at(1000000100, 10.1268 + arcsecOfRa(0.2, 56.5373), 56.5373, 71.0, { name: 'Gaia DR3 425040000962559616', magnitude: 1.94, distanceError: 0.035, distanceFromGaia: true, source: 'gaia' });
|
||||
const [merged] = mergeStarCatalogues([{ ...HIPPARCOS, stars: [hyg] }, { ...GAIA, stars: [gaia] }]).stars;
|
||||
expect(Math.hypot(merged.x, merged.y, merged.z)).toBeCloseTo(70.0, 9);
|
||||
expect(directionCosine(merged, gaia)).toBeCloseTo(1, 12);
|
||||
expect(merged).toMatchObject({ name: 'Schedar', distanceError: 0.0105, distanceFromGaia: false, source: 'gaia' });
|
||||
});
|
||||
|
||||
it('keeps two entries of one source apart, however close they are', () => {
|
||||
// Gaia resolves doubles Hipparcos saw as one star: two source ids 0.8″ apart are two stars,
|
||||
// and only *another* catalogue can claim to have already listed either of them.
|
||||
@@ -238,34 +318,136 @@ describe('mergeStarCatalogues', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('foldByIdentity', () => {
|
||||
// GJ 4285 as HYG has it, at its Gliese photometric distance and V, with no colour, and the Gaia
|
||||
// source SIMBAD names as the same star, L 119-44, 50.6″ away: G 13.05 and BP−RP 2.74, which give V 14.4.
|
||||
const gliese = at(119513, 339.5, -65.84, 6.8, { name: 'GJ 4285', source: 'hyg', magnitude: 11.45, magnitudeBand: 'V', spectralType: 'm', colorIndex: null });
|
||||
const gaia = at(1050005263, 339.5 + arcsecOfRa(50.6, -65.84), -65.84, 28.25, {
|
||||
name: 'Gaia DR3 6392188629658709888',
|
||||
gaiaDesignation: 'Gaia DR3 6392188629658709888',
|
||||
source: 'gaia',
|
||||
magnitude: 13.05,
|
||||
magnitudeBand: 'G',
|
||||
colorIndex: 2.74,
|
||||
colorSystem: 'BP-RP',
|
||||
distanceError: 0.0004,
|
||||
distanceFromGaia: true
|
||||
});
|
||||
const identities = new Map([[gliese.id, gaia.gaiaDesignation!]]);
|
||||
|
||||
it("folds a Gliese entry into the Gaia entry SIMBAD names it as, at Gaia's position and distance and in Gaia's photometry", () => {
|
||||
expect(isSameStar(gaia, gliese)).toBe(false);
|
||||
const { stars, folded } = foldByIdentity([gliese, gaia], identities);
|
||||
expect(folded).toBe(1);
|
||||
expect(stars).toHaveLength(1);
|
||||
expect(stars[0]).toMatchObject({ id: gliese.id, name: 'GJ 4285', spectralType: 'm', source: 'gaia', distanceError: 0.0004, distanceFromGaia: true });
|
||||
expect(stars[0]).toMatchObject({ magnitude: 13.05, magnitudeBand: 'G', colorIndex: 2.74, colorSystem: 'BP-RP' });
|
||||
expect(Math.hypot(stars[0].x, stars[0].y, stars[0].z)).toBeCloseTo(28.25, 9);
|
||||
expect(directionCosine(stars[0], gaia)).toBeCloseTo(1, 12);
|
||||
});
|
||||
|
||||
it('folds one into a Gaia entry a HYG star of the same brightness already describes, and keeps that star', () => {
|
||||
// HYG lists GJ 251 twice: HD 265866, a Hipparcos row merged with its Gaia source, and Gl 251,
|
||||
// 10.9″ away at a Gliese distance of 5.76 pc, which SIMBAD names as the same source.
|
||||
const hd265866 = at(33139, 103.7, 33.27, 5.58, { name: 'HD 265866', source: 'hyg', magnitude: 9.89, magnitudeBand: 'V', spectralType: 'M3', colorIndex: 1.6, colorSystem: 'B-V', distanceError: 0.004 });
|
||||
const source = at(1000033139, 103.7, 33.27, 5.585, { name: 'Gaia DR3 939072613334579328', gaiaDesignation: 'Gaia DR3 939072613334579328', source: 'gaia', magnitude: 8.9, magnitudeBand: 'G', distanceError: 0.0002 });
|
||||
const gl251 = at(118447, 103.7 + arcsecOfRa(10.9, 33.27), 33.27, 5.76, { name: 'Gl 251', source: 'hyg', magnitude: 10.01, magnitudeBand: 'V', spectralType: 'M4', colorIndex: null });
|
||||
const { stars: merged } = mergeStarCatalogues([{ ...HIPPARCOS, stars: [hd265866] }, { ...GAIA, stars: [source] }]);
|
||||
const { stars, folded } = foldByIdentity([...merged, gl251], new Map([[gl251.id, source.gaiaDesignation!]]));
|
||||
expect(folded).toBe(1);
|
||||
expect(stars).toHaveLength(1);
|
||||
expect(stars[0]).toMatchObject({ id: 33139, name: 'HD 265866', magnitude: 9.89, magnitudeBand: 'V', colorIndex: 1.6, source: 'gaia' });
|
||||
});
|
||||
|
||||
it('describes the entry by the Gliese row where SIMBAD names the HYG star already there as another source', () => {
|
||||
// HYG hangs "Gl 905.2A", M5, on HIP 117059; SIMBAD has HIP 117059 as LAWD 93, Gl 905.2B, a DA
|
||||
// white dwarf at 53.76 mas, and Gl 905.2A as G 130-6, a source 3′ away with no parallax.
|
||||
const hip117059 = at(116690, 355.96134, 32.54631, 17.13, { name: 'Gl 905.2A', source: 'hyg', magnitude: 13.11, magnitudeBand: 'V', spectralType: 'M5', colorIndex: 1.55, colorSystem: 'B-V', distanceError: 0.1 });
|
||||
const lawd93 = at(1000116690, 355.96134, 32.54631, 18.6, { name: 'Gaia DR3 2871730307948650368', gaiaDesignation: 'Gaia DR3 2871730307948650368', source: 'gaia', magnitude: 12.97, magnitudeBand: 'G', colorIndex: -0.04, colorSystem: 'BP-RP', distanceError: 0.0006 });
|
||||
const gl905b = at(119589, 355.96134 + arcsecOfRa(8, 32.54631), 32.54631, 16.64, { name: 'Gl 905.2B', source: 'hyg', magnitude: 12.9, magnitudeBand: 'V', spectralType: 'DA4', colorIndex: 0.15, colorSystem: 'B-V' });
|
||||
const { stars: merged } = mergeStarCatalogues([{ ...HIPPARCOS, stars: [hip117059] }, { ...GAIA, stars: [lawd93] }]);
|
||||
const identities = new Map([
|
||||
[hip117059.id, 'Gaia DR3 2871730758921709952'],
|
||||
[gl905b.id, lawd93.gaiaDesignation!]
|
||||
]);
|
||||
const { stars, folded } = foldByIdentity([...merged, gl905b], identities);
|
||||
expect(folded).toBe(1);
|
||||
expect(stars).toHaveLength(1);
|
||||
expect(stars[0]).toMatchObject({ id: 119589, name: 'Gl 905.2B', spectralType: 'DA4', magnitude: 12.9, colorIndex: 0.15, source: 'gaia' });
|
||||
expect(Math.hypot(stars[0].x, stars[0].y, stars[0].z)).toBeCloseTo(18.6, 9);
|
||||
// Nor the M5's B−V where the row has no colour of its own (Gl 225.2C, beside HD 40887's).
|
||||
expect(foldByIdentity([...merged, { ...gl905b, colorIndex: null }], identities).stars[0].colorIndex).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves a star with a Hipparcos error alone, and one of another brightness than the HYG star already there', () => {
|
||||
expect(foldByIdentity([{ ...gliese, distanceError: 0.05 }, gaia], identities).folded).toBe(0);
|
||||
// A companion SIMBAD gives its primary's source: two magnitudes apart.
|
||||
expect(foldByIdentity([gliese, { ...gaia, name: 'L 119-44', magnitude: 13.45 }], identities).folded).toBe(0);
|
||||
expect(foldByIdentity([gliese, { ...gaia, name: 'L 119-44', magnitude: 11.85 }], identities).folded).toBe(1);
|
||||
expect(foldByIdentity([gliese, gaia], new Map()).folded).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hipparcosDistancePc', () => {
|
||||
it("takes HYG's distance where it gives one", () => {
|
||||
expect(hipparcosDistancePc(606.06, { parallaxMas: 1.65, relativeError: 0.45 / 1.65 })).toBe(606.06);
|
||||
});
|
||||
|
||||
it("places a star HYG gives no distance for by its parallax, if the parallax is 2.5 times its error", () => {
|
||||
// HD 74180 at 0.67 ± 0.16 mas and Mu Cep at 0.55 ± 0.20; a parallax at 1.5 times its error stays out.
|
||||
expect(hipparcosDistancePc(HYG_UNKNOWN_DISTANCE_PC, { parallaxMas: 0.67, relativeError: 0.16 / 0.67 })).toBeCloseTo(1492.5, 1);
|
||||
expect(hipparcosDistancePc(HYG_UNKNOWN_DISTANCE_PC, { parallaxMas: 0.55, relativeError: 0.2 / 0.55 })).toBeCloseTo(1818.2, 1);
|
||||
expect(hipparcosDistancePc(HYG_UNKNOWN_DISTANCE_PC, { parallaxMas: 0.3, relativeError: 0.2 / 0.3 })).toBeUndefined();
|
||||
expect(hipparcosDistancePc(HYG_UNKNOWN_DISTANCE_PC)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('placementDistancePc', () => {
|
||||
it("draws a star both surveys measured at Gaia's distance", () => {
|
||||
expect(placementDistancePc(120, 118.4, 250)).toBe(118.4);
|
||||
expect(placementDistancePc(120, 118.4, 8, 250)).toBe(118.4);
|
||||
});
|
||||
|
||||
// The case the old cut got wrong: Hipparcos inside, Gaia outside. Kept, at the distance Gaia
|
||||
// gives, rather than at one a third short or dropped for having been misplaced.
|
||||
it('keeps a star Hipparcos put inside the cutoff, where Gaia puts it, even past the cutoff', () => {
|
||||
expect(placementDistancePc(200, 306, 250)).toBe(306);
|
||||
expect(placementDistancePc(200, 306, 8, 250)).toBe(306);
|
||||
});
|
||||
|
||||
// The mirror image: Hipparcos outside, Gaia inside. The Gaia download already holds the star,
|
||||
// and keeping the HYG row is what lets the merge give that entry its name.
|
||||
it('keeps a star only Gaia puts inside the cutoff', () => {
|
||||
expect(placementDistancePc(262, 241, 250)).toBe(241);
|
||||
expect(placementDistancePc(262, 241, 8, 250)).toBe(241);
|
||||
});
|
||||
|
||||
it('keeps a star Gaia measured and Hipparcos gave no distance for', () => {
|
||||
expect(placementDistancePc(undefined, 180, 250)).toBe(180);
|
||||
expect(placementDistancePc(undefined, 180, 8, 250)).toBe(180);
|
||||
});
|
||||
|
||||
it("takes the distance with the smaller error, Hipparcos's where Gaia saturated", () => {
|
||||
// Eta Leo: 556.6 pc ±17 % in Gaia, 389.1 pc ±6.2 % in Hipparcos. Sirius the other way round.
|
||||
expect(placementDistancePc(389.1, 556.6, 3.5, 250, 0.062, 0.17)).toBe(389.1);
|
||||
expect(placementDistancePc(2.64, 2.67, -1.44, 250, 0.004, 0.002)).toBe(2.67);
|
||||
});
|
||||
|
||||
it('falls back to Hipparcos where Gaia has no usable distance', () => {
|
||||
expect(placementDistancePc(90, undefined, 250)).toBe(90);
|
||||
expect(placementDistancePc(90, undefined, 8, 250)).toBe(90);
|
||||
});
|
||||
|
||||
it('drops a star both surveys put outside, or neither measured', () => {
|
||||
expect(placementDistancePc(300, 410, 250)).toBeNull();
|
||||
expect(placementDistancePc(300, undefined, 250)).toBeNull();
|
||||
expect(placementDistancePc(undefined, undefined, 250)).toBeNull();
|
||||
expect(placementDistancePc(300, 410, 8, 250)).toBeNull();
|
||||
expect(placementDistancePc(300, undefined, 8, 250)).toBeNull();
|
||||
expect(placementDistancePc(undefined, undefined, 8, 250)).toBeNull();
|
||||
});
|
||||
|
||||
// Rigel: Hipparcos 265 pc, and no Gaia distance, since Gaia saturates on it. The cutoff bounds a
|
||||
// download, not what the sky shows, and a map without the middle of Orion's belt is not the sky.
|
||||
it('keeps a star the naked eye sees at any distance, at the better one', () => {
|
||||
expect(placementDistancePc(265, undefined, 0.18, 250)).toBe(265);
|
||||
expect(placementDistancePc(433, 802, NAKED_EYE_MAGNITUDE, 250)).toBe(802);
|
||||
expect(placementDistancePc(433, 802, NAKED_EYE_MAGNITUDE + 0.01, 250)).toBeNull();
|
||||
});
|
||||
|
||||
it('still drops a naked-eye star no survey gives a distance for', () => {
|
||||
expect(placementDistancePc(undefined, undefined, 3.3, 250)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,6 +47,25 @@ export const MERGE_ANGULAR_TOLERANCE_DEG = 15 / 3600;
|
||||
*/
|
||||
export const MERGE_CERTAIN_ANGULAR_TOLERANCE_DEG = 3 / 3600;
|
||||
|
||||
/**
|
||||
* Angular separation, in degrees, within which two entries that cross the sky together are one
|
||||
* star, and how closely their proper motions must agree (as a fraction of the kept entry's) to
|
||||
* say so. Past fifteen arcseconds, and past a distance conflict, a Gliese-only entry is still
|
||||
* often the same star: its position is off by up to a minute of arc and its distance is
|
||||
* photometric — GJ 1035 sits 21″ from its Gaia entry, GJ 3052 at half Gaia's distance. What
|
||||
* gives them away is their motion, which Gliese measured well: a few per cent from Gaia's.
|
||||
*
|
||||
* Once the nearby faint Gaia stars joined, 253 of the 602 Gliese-only stars left without a
|
||||
* counterpart had a Gaia entry within a minute of arc moving within a fifth of their own motion;
|
||||
* shifted a quarter of a degree, none did. A minute was not enough, though: 39 Gliese stars within
|
||||
* 25 pc still had a bare Gaia entry moving with them 60 to 150″ away, 36 of which SIMBAD names as
|
||||
* the same star — GJ 3618, LHS 288, drawn at 4.49 pc and again 94″ away at 4.83 — while shifted a
|
||||
* quarter of a degree, none did. So 160″. Brightness still has its say, so a co-moving companion
|
||||
* is not folded into its primary, and `fetchStars` gives HYG's motions only to those rows.
|
||||
*/
|
||||
export const MERGE_COMOVING_ANGULAR_TOLERANCE_DEG = 160 / 3600;
|
||||
export const MERGE_PROPER_MOTION_TOLERANCE = 0.2;
|
||||
|
||||
/**
|
||||
* How much fainter, and how much brighter, an entry may be than the one it is folded into and
|
||||
* still be the same star. Bands differ, and not symmetrically: a red dwarf is three magnitudes
|
||||
@@ -67,25 +86,62 @@ export const MERGE_BRIGHTER_TOLERANCE = 1;
|
||||
*/
|
||||
export const MERGE_DISTANCE_RATIO_TOLERANCE = 0.5;
|
||||
|
||||
/** HYG's distance for a star whose parallax it does not give one for. */
|
||||
export const HYG_UNKNOWN_DISTANCE_PC = 100000;
|
||||
|
||||
/** How many times its error a parallax HYG leaves out must be to place a star by: a 40 % error. */
|
||||
const MIN_HIPPARCOS_PARALLAX_OVER_ERROR = 2.5;
|
||||
|
||||
/**
|
||||
* A HYG star's Hipparcos distance: HYG's own, which is the inverse of van Leeuwen's 2007 parallax,
|
||||
* or where HYG gives its placeholder instead, that inverse if the parallax is at least 2.5 times its
|
||||
* error. HYG gives no distance under 1 mas whatever the error, while keeping less certain parallaxes
|
||||
* above it: 41 naked-eye stars were left off the map as having no distance, HD 74180 at
|
||||
* 0.67 ± 0.16 mas and Mu Cep at 0.55 ± 0.20 among them, while Alnilam at 1.65 ± 0.45 was drawn.
|
||||
*/
|
||||
export function hipparcosDistancePc(hygPc: number, parallax?: { parallaxMas: number; relativeError: number }): number | undefined {
|
||||
if (Number.isFinite(hygPc) && hygPc > 0 && hygPc < HYG_UNKNOWN_DISTANCE_PC) {
|
||||
return hygPc;
|
||||
}
|
||||
return parallax && parallax.relativeError <= 1 / MIN_HIPPARCOS_PARALLAX_OVER_ERROR ? 1000 / parallax.parallaxMas : undefined;
|
||||
}
|
||||
|
||||
/** The faintest star, in V, the naked eye sees under a dark sky: the traditional limit of 6.5. */
|
||||
export const NAKED_EYE_MAGNITUDE = 6.5;
|
||||
|
||||
/**
|
||||
* Where to draw a star Hipparcos and Gaia both measured, and whether the map keeps it at all.
|
||||
*
|
||||
* Gaia's distance wherever it has a usable one, since its parallaxes are fifty times more
|
||||
* precise; Hipparcos's otherwise. The two catalogues used to be cut at the same radius, each on
|
||||
* At whichever distance has the smaller relative error, given both; Gaia's without them, or
|
||||
* Hipparcos's where Gaia has none. Gaia's parallaxes are some fifty times more precise, and its
|
||||
* distance wins for all but 273 of the 88 781 HYG stars with both; those are bright stars Gaia
|
||||
* saturates on, 257 of them naked-eye — Eta Leo is 556.6 pc ±17 % in Gaia and 389 pc ±6.2 % in
|
||||
* Hipparcos, Schedar ±3.5 % against ±1.0 %. The two catalogues used to be cut at the same radius, each on
|
||||
* its own distance, so a star Hipparcos put at 200 pc and Gaia at 300 was kept by one, never
|
||||
* downloaded from the other, and drawn at 200. That was 83% of the HYG stars left without a
|
||||
* Gaia counterpart, and at the median Hipparcos had them at two-thirds of Gaia's distance.
|
||||
*
|
||||
* Now a star either survey places inside `cutoffPc` is kept, and every kept star sits where the
|
||||
* better measurement puts it, inside the cutoff or not. `null` for a star neither survey places
|
||||
* inside, or that no survey gives a distance for.
|
||||
* better measurement puts it, inside the cutoff or not. So is every star the naked eye sees, at
|
||||
* any distance: the cutoff took 1 543 of HYG's 8 920 stars of V 6.5 or brighter, Rigel, Deneb
|
||||
* and Alnilam among them, while 11th-magnitude Gaia stars at the same distance were drawn. Those
|
||||
* Gaia has no usable parallax for sit at their Hipparcos distance. `null` for a star kept by
|
||||
* neither rule, or that no survey gives a distance for.
|
||||
*/
|
||||
export function placementDistancePc(hipparcosPc: number | undefined, gaiaPc: number | undefined, cutoffPc: number): number | null {
|
||||
const best = gaiaPc ?? hipparcosPc;
|
||||
export function placementDistancePc(
|
||||
hipparcosPc: number | undefined,
|
||||
gaiaPc: number | undefined,
|
||||
magnitude: number,
|
||||
cutoffPc: number,
|
||||
hipparcosError?: number,
|
||||
gaiaError?: number
|
||||
): number | null {
|
||||
const hipparcosBetter = hipparcosPc !== undefined && hipparcosError !== undefined && gaiaError !== undefined && hipparcosError < gaiaError;
|
||||
const best = hipparcosBetter ? hipparcosPc : (gaiaPc ?? hipparcosPc);
|
||||
if (best === undefined) {
|
||||
return null;
|
||||
}
|
||||
const inside = best <= cutoffPc || (hipparcosPc !== undefined && hipparcosPc <= cutoffPc);
|
||||
const inside = magnitude <= NAKED_EYE_MAGNITUDE || best <= cutoffPc || (hipparcosPc !== undefined && hipparcosPc <= cutoffPc);
|
||||
return inside ? best : null;
|
||||
}
|
||||
|
||||
@@ -153,10 +209,19 @@ export function directionCosine(a: StarRecord, b: StarRecord): number {
|
||||
return Math.max(-1, Math.min(1, ax * bx + ay * by + az * bz));
|
||||
}
|
||||
|
||||
/** Whether both entries have a proper motion and `entry`'s is within tolerance of `kept`'s. */
|
||||
function movesWith(kept: StarRecord, entry: StarRecord): boolean {
|
||||
if (kept.pmRaMasYr === undefined || kept.pmDecMasYr === undefined || entry.pmRaMasYr === undefined || entry.pmDecMasYr === undefined) {
|
||||
return false;
|
||||
}
|
||||
const difference = Math.hypot(entry.pmRaMasYr - kept.pmRaMasYr, entry.pmDecMasYr - kept.pmDecMasYr);
|
||||
return difference < MERGE_PROPER_MOTION_TOLERANCE * Math.hypot(kept.pmRaMasYr, kept.pmDecMasYr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `entry` describes the star already `kept`: the same direction, the brightness not in
|
||||
* conflict and — unless the directions agree closely enough to settle it — the distance not in
|
||||
* conflict either.
|
||||
* conflict and — unless the directions agree closely enough to settle it, or the two move
|
||||
* together — the distance not in conflict either.
|
||||
*/
|
||||
export function isSameStar(kept: StarRecord, entry: StarRecord): boolean {
|
||||
const [near, far] = [distanceOf(kept), distanceOf(entry)].sort((p, q) => p - q);
|
||||
@@ -169,7 +234,8 @@ export function isSameStar(kept: StarRecord, entry: StarRecord): boolean {
|
||||
}
|
||||
|
||||
const separationDeg = Math.acos(directionCosine(kept, entry)) / DEG_TO_RAD;
|
||||
if (separationDeg > MERGE_ANGULAR_TOLERANCE_DEG) {
|
||||
const comoving = movesWith(kept, entry);
|
||||
if (separationDeg > (comoving ? MERGE_COMOVING_ANGULAR_TOLERANCE_DEG : MERGE_ANGULAR_TOLERANCE_DEG)) {
|
||||
return false;
|
||||
}
|
||||
const fainterBy = entry.magnitude - kept.magnitude;
|
||||
@@ -177,7 +243,7 @@ export function isSameStar(kept: StarRecord, entry: StarRecord): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (separationDeg <= MERGE_CERTAIN_ANGULAR_TOLERANCE_DEG) {
|
||||
if (comoving || separationDeg <= MERGE_CERTAIN_ANGULAR_TOLERANCE_DEG) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -190,12 +256,106 @@ export function isSameStar(kept: StarRecord, entry: StarRecord): boolean {
|
||||
* a catalogue number. HYG's "Proxima Centauri", "M5Ve" and V magnitude over Gaia's
|
||||
* "Gaia DR3 5853498713190525696", "Unknown" and G; keeping either row whole loses half of that,
|
||||
* and keeping Gaia's whole once cost the map 102 proper names and 32 000 spectral types. The id
|
||||
* travels with the description, so a star HYG knows keeps its HYG id from one refresh to the next;
|
||||
* `source` stays with the position, since that is what it records.
|
||||
* travels with the description, so a star HYG knows keeps its HYG id from one refresh to the next,
|
||||
* and so does its photometry, V and B−V. `source` stays with the position, since that is what it
|
||||
* records, and so does the distance's error: Gaia's, not the Hipparcos one of a distance dropped —
|
||||
* unless the other entry's distance is the more precise, as the Hipparcos one of a bright star
|
||||
* `placementDistancePc` keeps at it is, which then sets the distance along Gaia's direction.
|
||||
* `described` overrides that choice where an identity settles it (see {@link foldByIdentity}).
|
||||
*
|
||||
* A colour the description lacks comes from the other entry, in its own system: HYG has none for
|
||||
* HD 45951, HD 45291 and HD 124953, three naked-eye giants Gaia measures at BP−RP 1.25, 1.19 and
|
||||
* 0.37, and each card showed no colour at all.
|
||||
*/
|
||||
function combine(kept: StarRecord, other: StarRecord): StarRecord {
|
||||
const described = isDesignation(kept) && !isDesignation(other) ? other : kept;
|
||||
return { ...described, x: kept.x, y: kept.y, z: kept.z, source: kept.source };
|
||||
function combine(kept: StarRecord, other: StarRecord, described = isDesignation(kept) && !isDesignation(other) ? other : kept): StarRecord {
|
||||
const otherBetter = other.distanceError !== undefined && kept.distanceError !== undefined && other.distanceError < kept.distanceError;
|
||||
const placed = otherBetter ? other : kept;
|
||||
const scale = otherBetter ? distanceOf(other) / distanceOf(kept) : 1;
|
||||
const gaiaDesignation = kept.gaiaDesignation ?? other.gaiaDesignation;
|
||||
const coloured = described.colorIndex !== null ? described : described === kept ? other : kept;
|
||||
return {
|
||||
...described,
|
||||
colorIndex: coloured.colorIndex,
|
||||
colorSystem: coloured.colorSystem,
|
||||
x: kept.x * scale,
|
||||
y: kept.y * scale,
|
||||
z: kept.z * scale,
|
||||
source: kept.source,
|
||||
distanceError: placed.distanceError,
|
||||
distanceFromGaia: placed.distanceFromGaia,
|
||||
...(gaiaDesignation === undefined ? {} : { gaiaDesignation })
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* How far apart in V a Gliese-only entry and the HYG star already on its Gaia entry may be and still
|
||||
* be one star. Of the 14 such folds, 11 agree within 0.12 (Gl 251 and HD 265866, 10.01 and 9.89);
|
||||
* Gl 905.2B, Gl 225.2C and 69 Tau Oph differ by 0.21, 0.45 and 0.47, and on all three SIMBAD puts
|
||||
* the HYG star already there on another source (see {@link foldByIdentity}). A companion SIMBAD gives
|
||||
* its primary's source differs by magnitudes.
|
||||
*/
|
||||
export const IDENTITY_FOLD_MAGNITUDE_TOLERANCE = 0.5;
|
||||
|
||||
/**
|
||||
* Whether a Gliese-only entry folds into `target`, the Gaia entry of the source SIMBAD names it as:
|
||||
* always where that entry is still bare, a Gaia designation with Gaia's G, which SIMBAD's identity
|
||||
* settles whatever HYG's V says; where a HYG star already describes it, only if the two V agree.
|
||||
*/
|
||||
export function foldsInto(target: StarRecord, entry: StarRecord): boolean {
|
||||
return isDesignation(target) || Math.abs(target.magnitude - entry.magnitude) <= IDENTITY_FOLD_MAGNITUDE_TOLERANCE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Folds each Gliese-only entry — HYG's, with no Hipparcos astrometry and so no published error on
|
||||
* its distance — into the Gaia entry of the source SIMBAD names it as, `gaiaDesignationById` by
|
||||
* HYG id (see {@link foldsInto}). {@link isSameStar} cannot see these: 43 of them within 25 pc stayed
|
||||
* beside their own bare Gaia entry, too far for their positions (GJ 3478, 16″), moving differently
|
||||
* by HYG's motions (GJ 2097, 39 %), or brighter in HYG's V than Gaia's G by more than a primary may
|
||||
* be (GJ 4285, 1.6 magnitudes). Two of those were stars that do not exist inside 10 pc: GJ 2097 at
|
||||
* 6.41 pc and GJ 4285 at 6.80, which Gaia measures at 24.47 and 28.25. Others sat beside a Gaia entry
|
||||
* a Hipparcos row of HYG's had already taken, HYG listing the star twice: Gl 251 at 5.76 pc beside
|
||||
* HD 265866, the host of GJ 251 b and c, at 5.58.
|
||||
*
|
||||
* The fold keeps the entry already there's photometry along with Gaia's position and distance, and
|
||||
* HYG's name and type. Taking the Gliese row's, as {@link combine} would, put CNS3's V at Gaia's
|
||||
* distance: GJ 4285 at V 11.45, where its G 13.05 and BP−RP 2.74 give 14.4, drawn five times too
|
||||
* luminous, and six stars with no colour lost their temperature and radius, Gl 700.1C among them.
|
||||
*
|
||||
* Unless SIMBAD names the HYG star already there as another source: then HYG hung it on the wrong
|
||||
* one, and the Gliese row is the star that source is, so its description — photometry included —
|
||||
* replaces that one. HIP 117059 is LAWD 93, the white dwarf Gl 905.2B (DA, V 12.94 in SIMBAD), which
|
||||
* HYG labels "Gl 905.2A", M5, V 13.11, B−V 1.55: kept, the white dwarf was drawn as a 3 384 K red
|
||||
* dwarf 15 times its radius. Five of the 63 folds are of this kind, GJ 9490C, Gl 225.2C, 69 Tau Oph
|
||||
* A and HD 65277 (Gl 293.1A) the others; each HYG star SIMBAD puts elsewhere loses its entry,
|
||||
* having no source of its own on the map to carry it.
|
||||
*/
|
||||
export function foldByIdentity(stars: readonly StarRecord[], gaiaDesignationById: ReadonlyMap<number, string>): { stars: StarRecord[]; folded: number } {
|
||||
const byDesignation = new Map<string, number>();
|
||||
stars.forEach((star, index) => {
|
||||
if (star.gaiaDesignation !== undefined) {
|
||||
byDesignation.set(star.gaiaDesignation, index);
|
||||
}
|
||||
});
|
||||
const result = [...stars];
|
||||
const folded = new Set<number>();
|
||||
stars.forEach((star, index) => {
|
||||
const designation = star.source === 'hyg' && star.distanceError === undefined ? gaiaDesignationById.get(star.id) : undefined;
|
||||
const target = designation === undefined ? undefined : byDesignation.get(designation);
|
||||
if (target === undefined || !foldsInto(result[target], star)) {
|
||||
return;
|
||||
}
|
||||
const describer = gaiaDesignationById.get(result[target].id);
|
||||
const { magnitude, magnitudeBand, colorIndex, colorSystem } = result[target];
|
||||
// Where the Gliese row is the star, its colour too, or none: not the one of the star SIMBAD puts elsewhere.
|
||||
result[target] =
|
||||
describer !== undefined && describer !== designation
|
||||
? { ...combine(result[target], star, star), colorIndex: star.colorIndex, colorSystem: star.colorSystem }
|
||||
: { ...combine(result[target], star), magnitude, magnitudeBand, colorIndex, colorSystem };
|
||||
// One star each: a second Gliese row naming the same source is another star SIMBAD has not split.
|
||||
byDesignation.delete(designation!);
|
||||
folded.add(index);
|
||||
});
|
||||
return { stars: result.filter((_, index) => !folded.has(index)), folded: folded.size };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { absoluteMagnitude, bolometricCorrection, luminositySolar, SOLAR_ABSOLUTE_MAGNITUDE_V, SOLAR_BOLOMETRIC_MAGNITUDE } from './stellar';
|
||||
import {
|
||||
absoluteMagnitude,
|
||||
blackbodyColor,
|
||||
bolometricCorrection,
|
||||
effectiveTemperatureK,
|
||||
giantSurface,
|
||||
luminositySolar,
|
||||
radiusFromLuminositySolar,
|
||||
SOLAR_ABSOLUTE_MAGNITUDE_V,
|
||||
SOLAR_BOLOMETRIC_MAGNITUDE,
|
||||
SOLAR_EFFECTIVE_TEMPERATURE_K
|
||||
} from './stellar';
|
||||
import { dwarfSequenceAtType } from './spectral';
|
||||
|
||||
/** Real catalogue rows, with the published luminosity each one should reproduce. */
|
||||
const SIRIUS = { magnitude: -1.44, distancePc: 2.6371, spectralType: 'A0m...', publishedLuminosity: 25.4 };
|
||||
@@ -96,6 +108,98 @@ describe('luminositySolar', () => {
|
||||
expect(luminositySolar(PROXIMA)!).toBeGreaterThan(uncorrected * 5);
|
||||
});
|
||||
|
||||
it('reads the correction off the colour where the catalogue has no type', () => {
|
||||
// Barnard's Star, 0.0035 L☉ (Dawson & De Robertis 2004), as a star no one classified: the
|
||||
// Sun's correction left it at an eighth of that.
|
||||
const derived = luminositySolar({ magnitude: 9.54, distancePc: 1.8266, spectralType: 'Unknown', magnitudeBand: 'V', colorIndex: 1.57, colorSystem: 'B-V' })!;
|
||||
expect(derived / 0.0035).toBeGreaterThan(1 / 1.5);
|
||||
expect(derived / 0.0035).toBeLessThan(1.5);
|
||||
});
|
||||
|
||||
it('carries a Gaia G magnitude to V before correcting it', () => {
|
||||
// TRAPPIST-1 as Gaia has it, 5.53e-4 L☉ (Agol et al. 2021). Read as V its G is 3.1
|
||||
// magnitudes too bright, and its luminosity comes out seventeen times too high.
|
||||
const derived = luminositySolar({ magnitude: 15.6226, distancePc: 12.467, spectralType: 'Unknown', magnitudeBand: 'G', colorIndex: 4.902, colorSystem: 'BP-RP' })!;
|
||||
expect(derived / 5.53e-4).toBeGreaterThan(1 / 1.5);
|
||||
expect(derived / 5.53e-4).toBeLessThan(1.5);
|
||||
});
|
||||
|
||||
it("reads a giant's temperature and correction both off its type, not the cooler dwarf's its colour reads as", () => {
|
||||
// Antares, M1 Ib at B−V 1.87: 3 660 K (Ohnaka et al. 2013), where its colour's dwarf is 3 019.
|
||||
const antares = { magnitude: 1.06, distancePc: 169.78, spectralType: 'M1Ib + B2.5V', magnitudeBand: 'V', colorIndex: 1.865, colorSystem: 'B-V' } as const;
|
||||
expect(Math.abs(effectiveTemperatureK(antares)! - 3660)).toBeLessThan(100);
|
||||
// Each piece of van Belle et al.'s (2021) table 8, at G0 = 50, K0 = 60, M0 = 66 on its index:
|
||||
// G8 III 7856 − 52.74 × 58, K2 III 16751 − 199.41 × 62, and flat at 3 134 K from M6 III.
|
||||
const giant = (spectralType: string) => effectiveTemperatureK({ magnitude: 5, distancePc: 100, spectralType, colorIndex: null });
|
||||
expect(giant('G8III')).toBeCloseTo(4797.08, 1);
|
||||
expect(giant('K2III')).toBeCloseTo(4387.58, 1);
|
||||
expect(giant('M5.5III')).toBeCloseTo(3343.43, 1);
|
||||
expect(giant('M6III')).toBe(3134);
|
||||
expect(giant('M7III')).toBe(3134);
|
||||
// Aldebaran, K5 III, 44.2 R☉ (Richichi & Roccatagliata 2005), to a tenth; Rigel, B8 Ia, 74.1
|
||||
// (Baines et al. 2018), to a fifth. With a correction off the type beside the colour's
|
||||
// temperature, Rigel came out 101.6; with K5's own correction at 3 902 K, Aldebaran 52.
|
||||
const aldebaran = { magnitude: 0.87, distancePc: 20.433, spectralType: 'K5III', magnitudeBand: 'V', colorIndex: 1.538, colorSystem: 'B-V' } as const;
|
||||
const rigel = { magnitude: 0.18, distancePc: 264.55, spectralType: 'B8Ia', magnitudeBand: 'V', colorIndex: -0.03, colorSystem: 'B-V' } as const;
|
||||
for (const [star, published, tolerance] of [[aldebaran, 44.2, 1.1], [rigel, 74.1, 1.2]] as const) {
|
||||
const radius = radiusFromLuminositySolar(luminositySolar(star)!, effectiveTemperatureK(star)!);
|
||||
expect(radius / published).toBeGreaterThan(1 / tolerance);
|
||||
expect(radius / published).toBeLessThan(tolerance);
|
||||
}
|
||||
});
|
||||
|
||||
it("draws an M6 giant at the radius its measured diameter gives, with an M giant's correction, not a dwarf's", () => {
|
||||
// RZ Ari (ρ² Ari) and EU Del as the catalogue has them, and their limb-darkened diameters in
|
||||
// CHARM2 (Richichi et al. 2005), 10.30 and 9.90 mas: 119 and 126 R☉ at those distances. With the
|
||||
// dwarf's −2.76 at 3 134 K they came out 81.5 and 72.6; van Belle's own M6 giants give −3.94.
|
||||
const rzAri = { magnitude: 5.76, distancePc: 107.76, spectralType: 'M6IIIvar', magnitudeBand: 'V', colorIndex: 1.452, colorSystem: 'B-V' } as const;
|
||||
const euDel = { magnitude: 6.22, distancePc: 118.6, spectralType: 'M6III', magnitudeBand: 'V', colorIndex: 1.162, colorSystem: 'B-V' } as const;
|
||||
for (const [star, diameterMas] of [[rzAri, 10.3], [euDel, 9.9]] as const) {
|
||||
const measured = 0.10753 * diameterMas * star.distancePc;
|
||||
const radius = radiusFromLuminositySolar(luminositySolar(star)!, effectiveTemperatureK(star)!);
|
||||
expect(radius / measured).toBeGreaterThan(1 / 1.2);
|
||||
expect(radius / measured).toBeLessThan(1.2);
|
||||
}
|
||||
});
|
||||
|
||||
it("draws van Belle's own M4, M5 and M7 giants at their measured radii, and holds the correction past M7.5", () => {
|
||||
// Median Johnson V, parallax and radius from van Belle et al. (2021) tables 6 and 4, none behind
|
||||
// more than 0.02 mag of dust: 103.1, 103.2 and 173.3 R☉, drawn at 1.00, 1.00 and 0.91 of that.
|
||||
// A dwarf's correction draws M4 18 % smaller and M5 26 %, and M6's −3.94 draws M7 25 % smaller;
|
||||
// hence 15 %, not the M6 case's 20 %.
|
||||
const giants = [
|
||||
[{ magnitude: 7.72, distancePc: 1000 / 1.87, spectralType: 'M4III', magnitudeBand: 'V' }, 103.11], // HD 118669
|
||||
[{ magnitude: 6.97, distancePc: 1000 / 3.45, spectralType: 'M5III', magnitudeBand: 'V' }, 103.15], // HD 104207
|
||||
[{ magnitude: 9.29, distancePc: 1000 / 2.17, spectralType: 'M7III', magnitudeBand: 'V' }, 173.31] // HIP 68357
|
||||
] as const;
|
||||
for (const [star, measured] of giants) {
|
||||
const radius = radiusFromLuminositySolar(luminositySolar(star)!, effectiveTemperatureK(star)!);
|
||||
expect(radius / measured).toBeGreaterThan(1 / 1.15);
|
||||
expect(radius / measured).toBeLessThan(1.15);
|
||||
}
|
||||
// Six M8 III stars in the catalogue, past the table's last type, take its −4.86, not its first.
|
||||
expect(giantSurface('M8III')!.bolometricCorrectionV).toBe(giantSurface('M7.5III')!.bolometricCorrectionV);
|
||||
});
|
||||
|
||||
it('reads a hot giant reddened by dust at its type, not at the cool star its colour reads as', () => {
|
||||
// Menkib, O7.5 Iab at B−V 0.02: 14 R☉ (Krtička & Kubát 2010). At its colour's 9 517 K and its
|
||||
// type's correction it was drawn at 95; the dust it is behind still leaves it dimmer than it is.
|
||||
const menkib = { magnitude: 3.98, distancePc: 408.881, spectralType: 'O7.5Iab:', magnitudeBand: 'V', colorIndex: 0.016, colorSystem: 'B-V' } as const;
|
||||
expect(effectiveTemperatureK(menkib)).toBeCloseTo(36100, 6);
|
||||
const radius = radiusFromLuminositySolar(luminositySolar(menkib)!, effectiveTemperatureK(menkib)!);
|
||||
expect(radius / 14).toBeGreaterThan(1 / 2.5);
|
||||
expect(radius / 14).toBeLessThan(2.5);
|
||||
});
|
||||
|
||||
it("gives a carbon star the carbon stars' correction and temperature, not the Sun's correction at an M dwarf's", () => {
|
||||
// La Superba, C7 Iab: Bergeat et al. (2001) have it at bolometric magnitude 2.43, which at the
|
||||
// catalogue's 310 pc is 8 090 L☉. The Sun's −0.06 at 2 420 K gave 544 L☉ and 133 R☉.
|
||||
const laSuperba = { magnitude: 5.42, distancePc: 310.342, spectralType: 'C7Iab', magnitudeBand: 'V', colorIndex: 2.994, colorSystem: 'B-V' } as const;
|
||||
expect(luminositySolar(laSuperba)! / 8090).toBeGreaterThan(1 / 1.2);
|
||||
expect(luminositySolar(laSuperba)! / 8090).toBeLessThan(1.2);
|
||||
expect(effectiveTemperatureK(laSuperba)).toBe(2990);
|
||||
});
|
||||
|
||||
it('clamps a pathological record instead of producing an absurd luminosity', () => {
|
||||
const absurd = luminositySolar({ magnitude: -40, distancePc: 5000, spectralType: 'O5V' })!;
|
||||
expect(Number.isFinite(absurd)).toBe(true);
|
||||
@@ -106,3 +210,113 @@ describe('luminositySolar', () => {
|
||||
expect(luminositySolar({ magnitude: 5, distancePc: -1 })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('effectiveTemperatureK', () => {
|
||||
it("is the Sun's own for the Sun", () => {
|
||||
expect(effectiveTemperatureK({ magnitude: -26.7, distancePc: 0, colorIndex: 0.7 })).toBe(SOLAR_EFFECTIVE_TEMPERATURE_K);
|
||||
});
|
||||
|
||||
it('reads a colour in its own system, and a spectral type where there is no colour', () => {
|
||||
// An M5 dwarf is 3 060 K at B−V 1.83 or BP−RP 3.35, and at its type alone.
|
||||
expect(effectiveTemperatureK({ magnitude: 11, distancePc: 5, colorIndex: 3.35, colorSystem: 'BP-RP' })).toBeCloseTo(3060, 0);
|
||||
expect(effectiveTemperatureK({ magnitude: 11, distancePc: 5, colorIndex: 1.83, colorSystem: 'B-V' })).toBeCloseTo(3060, 0);
|
||||
expect(effectiveTemperatureK({ magnitude: 11, distancePc: 5, spectralType: 'M5Ve', colorIndex: null })).toBeCloseTo(3060, 0);
|
||||
expect(effectiveTemperatureK({ magnitude: 11, distancePc: 5, spectralType: 'Unknown', colorIndex: null })).toBeNull();
|
||||
});
|
||||
|
||||
it('reads a colour past the table at its end where there is no type, and the type where there is', () => {
|
||||
// An ultracool dwarf redder than M8.5, a white dwarf bluer than B9 at the 19 012 K Gentile
|
||||
// Fusillo et al. (2021) measure at its colour, not B9's 10 700, and an O star at its type's
|
||||
// 35 100 K, where B−V puts every O star at B0's 31 400.
|
||||
expect(effectiveTemperatureK({ magnitude: 14.005, distancePc: 4.005, spectralType: 'Unknown', magnitudeBand: 'G', colorIndex: 5.113, colorSystem: 'BP-RP' })).toBe(2420);
|
||||
expect(effectiveTemperatureK({ magnitude: 14, distancePc: 25, spectralType: 'Unknown', magnitudeBand: 'G', colorIndex: -0.25, colorSystem: 'BP-RP' })).toBeCloseTo(19012, 6);
|
||||
expect(effectiveTemperatureK({ magnitude: 7, distancePc: 121, spectralType: 'O8', colorIndex: -0.31, colorSystem: 'B-V' })).toBe(35100);
|
||||
// HD 49748, G5 V at B−V −0.32: the colour is the one that is wrong.
|
||||
const g5 = effectiveTemperatureK({ magnitude: 9, distancePc: 184, spectralType: 'G5V', colorIndex: null })!;
|
||||
expect(effectiveTemperatureK({ magnitude: 9, distancePc: 184, spectralType: 'G5V', colorIndex: -0.319, colorSystem: 'B-V' })).toBe(g5);
|
||||
expect(g5).toBeGreaterThan(5500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a dwarf with a type and no colour', () => {
|
||||
it("is drawn at its type's row of the dwarf sequence, not at the textbook colour of its type", () => {
|
||||
// GJ 3655, M8 at V 19.57 and 14.35 pc: M8 V is 2 570 K and 0.114 R☉ (Mamajek's table, 2022.04.16).
|
||||
// Through the textbook colour, B−V 1.88, the table's M5, and the textbook correction, −3.92
|
||||
// where M8's is −5.65, it was 3 001 K and 0.035 R☉, a third of Jupiter.
|
||||
const gj3655 = { magnitude: 19.57, distancePc: 14.35, spectralType: 'M8', magnitudeBand: 'V', colorIndex: null } as const;
|
||||
expect(effectiveTemperatureK(gj3655)).toBeCloseTo(2570, 6);
|
||||
const radius = radiusFromLuminositySolar(luminositySolar(gj3655)!, effectiveTemperatureK(gj3655)!);
|
||||
expect(radius / 0.114).toBeGreaterThan(1 / 1.2);
|
||||
expect(radius / 0.114).toBeLessThan(1.2);
|
||||
});
|
||||
|
||||
it("carries a G magnitude to V at its type's G−V", () => {
|
||||
// The same star measured in G, which for an M8 dwarf reads 3.11 magnitudes brighter than V: taken
|
||||
// as V, it came out 17 times as luminous. One published star takes this path, Oph 11 (M9, G 18.91).
|
||||
const inV = { magnitude: 19.57, distancePc: 14.35, spectralType: 'M8', magnitudeBand: 'V', colorIndex: null } as const;
|
||||
const inG = { ...inV, magnitude: 19.57 + dwarfSequenceAtType('M8')!.gMinusV!, magnitudeBand: 'G' } as const;
|
||||
expect(dwarfSequenceAtType('M8')!.gMinusV).toBeLessThan(-3);
|
||||
expect(luminositySolar(inG)!).toBeCloseTo(luminositySolar(inV)!, 12);
|
||||
});
|
||||
});
|
||||
|
||||
describe('radiusFromLuminositySolar', () => {
|
||||
it('is one for the Sun', () => {
|
||||
expect(radiusFromLuminositySolar(1, SOLAR_EFFECTIVE_TEMPERATURE_K)).toBeCloseTo(1, 12);
|
||||
});
|
||||
|
||||
it("gives an ultracool dwarf redder than the table an M8.5 dwarf's radius, not none", () => {
|
||||
// Gaia DR3 6439125097427143808, 4.0 pc away at BP−RP 5.11; M8.5 V is 0.104 R☉ (Mamajek).
|
||||
const star = { magnitude: 14.005, distancePc: 4.005, spectralType: 'Unknown', magnitudeBand: 'G', colorIndex: 5.113, colorSystem: 'BP-RP' } as const;
|
||||
const radius = radiusFromLuminositySolar(luminositySolar(star)!, effectiveTemperatureK(star)!);
|
||||
expect(radius / 0.104).toBeGreaterThan(1 / 1.2);
|
||||
expect(radius / 0.104).toBeLessThan(1.2);
|
||||
});
|
||||
|
||||
it('gives a white dwarf bluer than the table the radius its mass and gravity give', () => {
|
||||
// Gaia DR3 6791196382856581376, 24.5 pc: 19 205 K, log g 8.07 and 0.66 M☉ in Gentile Fusillo et
|
||||
// al. (2021), so 0.01245 R☉. At B9's 10 700 K it came out about 1.5 times that.
|
||||
const star = { magnitude: 12.9198, distancePc: 24.5237, spectralType: 'Unknown', magnitudeBand: 'G', colorIndex: -0.2539, colorSystem: 'BP-RP' } as const;
|
||||
const radius = radiusFromLuminositySolar(luminositySolar(star)!, effectiveTemperatureK(star)!);
|
||||
expect(radius / 0.01245).toBeGreaterThan(1 / 1.2);
|
||||
expect(radius / 0.01245).toBeLessThan(1.2);
|
||||
});
|
||||
|
||||
it('gives Sirius and TRAPPIST-1 their published radii from colour and brightness alone', () => {
|
||||
// 1.711 R☉ (Liebert et al. 2005) and 0.119 R☉ (Agol et al. 2021), each to within a fifth.
|
||||
for (const [star, published] of [
|
||||
[{ magnitude: -1.44, distancePc: 2.6371, magnitudeBand: 'V', colorIndex: 0.009, colorSystem: 'B-V' }, 1.711],
|
||||
[{ magnitude: 15.6226, distancePc: 12.467, magnitudeBand: 'G', colorIndex: 4.902, colorSystem: 'BP-RP' }, 0.119]
|
||||
] as const) {
|
||||
const radius = radiusFromLuminositySolar(luminositySolar(star)!, effectiveTemperatureK(star)!);
|
||||
expect(radius / published).toBeGreaterThan(0.8);
|
||||
expect(radius / published).toBeLessThan(1.2);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('blackbodyColor', () => {
|
||||
/** As the display shows it: sRGB-encoded, 0 to 255. */
|
||||
const displayed = (rgb: readonly number[]) => rgb.map((v) => Math.round(255 * (v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055)));
|
||||
|
||||
it('gives the colours of the stars against the display white', () => {
|
||||
// Charity's blackbody colour table (CIE 1931 2°, D65): 2 900 K #ffb662, 5 800 K #fff1e7, 9 600 K #d3ddff.
|
||||
for (const [temperatureK, expected] of [[2900, [255, 182, 98]], [5800, [255, 241, 231]], [9600, [211, 221, 255]]] as const) {
|
||||
displayed(blackbodyColor(temperatureK)).forEach((channel, i) => expect(Math.abs(channel - expected[i])).toBeLessThanOrEqual(5));
|
||||
}
|
||||
});
|
||||
|
||||
it("is white at the white point it is given, and an M dwarf's light orange-red against the Sun's", () => {
|
||||
expect(blackbodyColor(SOLAR_EFFECTIVE_TEMPERATURE_K, SOLAR_EFFECTIVE_TEMPERATURE_K)).toEqual([1, 1, 1]);
|
||||
const [r, g, b] = blackbodyColor(2566, SOLAR_EFFECTIVE_TEMPERATURE_K);
|
||||
expect(r).toBe(1);
|
||||
expect(g).toBeCloseTo(0.44, 2);
|
||||
expect(b).toBeCloseTo(0.1, 2);
|
||||
});
|
||||
|
||||
it('holds the ends of the fit, and never goes negative', () => {
|
||||
expect(blackbodyColor(800)).toEqual(blackbodyColor(1667));
|
||||
expect(blackbodyColor(60000)).toEqual(blackbodyColor(25000));
|
||||
expect(Math.min(...blackbodyColor(1667))).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { parseSpectralClass, SpectralClass } from './spectral';
|
||||
import { DwarfSequencePoint, dwarfSequenceAtColor, dwarfSequenceAtTemperature, dwarfSequenceAtType, isGiant, parseSpectralClass, SpectralClass } from './spectral';
|
||||
|
||||
/**
|
||||
* Stellar luminosity, derived from the two things the star catalogue actually measures.
|
||||
@@ -91,11 +91,15 @@ export function bolometricCorrection(spectralType: string | null | undefined): n
|
||||
|
||||
/** Everything about a star that bears on how much light it puts out. */
|
||||
export interface StellarPhotometry {
|
||||
/** Apparent visual magnitude, as catalogued. */
|
||||
/** Apparent magnitude, as catalogued, in `magnitudeBand`. */
|
||||
magnitude: number;
|
||||
/** Distance from the Sun in parsecs; `0` identifies the Sun itself. */
|
||||
distancePc: number;
|
||||
spectralType?: string;
|
||||
/** V, or Gaia's G — which for an M5 dwarf reads 1.7 magnitudes brighter. Taken as V if absent. */
|
||||
magnitudeBand?: 'V' | 'G';
|
||||
colorIndex?: number | null;
|
||||
colorSystem?: 'B-V' | 'BP-RP';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -118,7 +122,205 @@ export function luminositySolar(star: StellarPhotometry): number | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bolometric = absolute + bolometricCorrection(star.spectralType);
|
||||
// Where the star has a colour the dwarf sequence covers, its correction is read off that colour,
|
||||
// and a G magnitude is carried to V first; otherwise both are read off the same table at its
|
||||
// spectral type, and only with neither is a G magnitude taken as V. Gaia classifies none of its
|
||||
// stars, so every one of them used to be given the Sun's correction, and TRAPPIST-1 came out at
|
||||
// a seventh of its luminosity. Against the archive's own figure for 1 449 hosts, the worst tenth
|
||||
// was off by 0.29 dex or more, and is now off by 0.12. A type with no colour took the textbook
|
||||
// anchors below instead, M8 −3.92 where the table has −5.65: GJ 3655, M8, came out 8.9×10⁻⁵ L☉
|
||||
// at 3 001 K and 0.035 R☉, where its type's row gives 2 570 K and 0.106.
|
||||
//
|
||||
// Not for a star its type says is a giant, though, whose correction is read off its type along
|
||||
// with its temperature: see giantSurface.
|
||||
const sequence = sequenceAtColour(star) ?? dwarfSequenceAtType(star.spectralType);
|
||||
const absoluteV = absolute - (star.magnitudeBand === 'G' ? (sequence?.gMinusV ?? 0) : 0);
|
||||
const correction = giantSurface(star.spectralType)?.bolometricCorrectionV ?? sequence?.bolometricCorrectionV ?? bolometricCorrection(star.spectralType);
|
||||
const bolometric = absoluteV + correction;
|
||||
const luminosity = Math.pow(10, (SOLAR_BOLOMETRIC_MAGNITUDE - bolometric) / 2.5);
|
||||
return Math.min(Math.max(luminosity, MIN_LUMINOSITY_SOLAR), MAX_LUMINOSITY_SOLAR);
|
||||
}
|
||||
|
||||
/**
|
||||
* The dwarf sequence at a star's colour — past the table's end, where the star has no type to go
|
||||
* by instead, at the end a colour is past. Past the red end are the ultracool dwarfs Gaia measures
|
||||
* redder than BP−RP 5.1, M8.5, and past B−V's blue end its O stars; Gaia's white dwarfs, bluer
|
||||
* than BP−RP −0.12, are read at the temperature white dwarfs of their colour are measured at.
|
||||
* Unread, they had no temperature and were drawn at the Sun's: Gaia DR3 6439125097427143808, an
|
||||
* ultracool dwarf 4.0 pc away, and 110 white dwarfs within 50 pc, all at 1 R☉. Beside a type, an
|
||||
* off-table colour is more often a bad one than an extreme star — HD 49748, G5 V, at B−V −0.32 —
|
||||
* and the type is read instead.
|
||||
*/
|
||||
function sequenceAtColour(star: Pick<StellarPhotometry, 'spectralType' | 'colorIndex' | 'colorSystem'>): DwarfSequencePoint | null {
|
||||
if (star.colorIndex == null) {
|
||||
return null;
|
||||
}
|
||||
return dwarfSequenceAtColor(star.colorIndex, star.colorSystem) ?? (parseSpectralClass(star.spectralType) ? null : dwarfSequenceAtColor(star.colorIndex, star.colorSystem, true));
|
||||
}
|
||||
|
||||
/** The Sun's effective temperature, the IAU 2015 nominal value. */
|
||||
export const SOLAR_EFFECTIVE_TEMPERATURE_K = 5772;
|
||||
|
||||
/**
|
||||
* Effective temperature, off the dwarf sequence at the star's colour, or at its spectral type where
|
||||
* it has none; a giant's off its type (giantSurface). Exactly the Sun's for the Sun, which is at
|
||||
* zero distance here. The type was read through the textbook colour `spectralTypeToColorIndex`
|
||||
* gives it, which the table puts elsewhere: M8's 1.88 is M5's, 3 001 K where M8 is 2 570, and every
|
||||
* O type came out B0's 31 400.
|
||||
*/
|
||||
export function effectiveTemperatureK(star: StellarPhotometry): number | null {
|
||||
if (star.distancePc === 0) {
|
||||
return SOLAR_EFFECTIVE_TEMPERATURE_K;
|
||||
}
|
||||
return giantSurface(star.spectralType)?.temperatureK ?? (sequenceAtColour(star) ?? dwarfSequenceAtType(star.spectralType))?.temperatureK ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether {@link effectiveTemperatureK} reads the star off its colour rather than off its type: not
|
||||
* for a giant, nor for a star with no colour the table reads, which since 206e88a is placed at its
|
||||
* type's row: 224 stars that are not giants with no colour, and 36 whose colour is off the table
|
||||
* (HD 49748, G5 V at B−V −0.32). The card said their radii came "from colour and brightness".
|
||||
*/
|
||||
export function temperatureFromColour(star: Pick<StellarPhotometry, 'spectralType' | 'colorIndex' | 'colorSystem'>): boolean {
|
||||
return giantSurface(star.spectralType) === null && sequenceAtColour(star) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a giant's type says of its surface: its effective temperature, and its bolometric
|
||||
* correction — the dwarf sequence's at that temperature, or from M0 an M giant's own
|
||||
* ({@link M_GIANT_CORRECTIONS}) — both off the type, so that the two a radius is drawn from come
|
||||
* from the same place. `null` for a star that is not a giant, or whose class the parser cannot read.
|
||||
*
|
||||
* Read off the colour, a giant is the dwarf of its colour, too cool: Antares, M1 Ib at B−V 1.87,
|
||||
* came out 3 019 K against the 3 660 Ohnaka et al. (2013) measure, and the 610 M giants a median
|
||||
* 3 275 K where M2 III is about 3 650. Worse, a hot giant behind dust reads as a far cooler star:
|
||||
* Menkib, O7.5 Iab at B−V 0.02, was 9 517 K, and with its type's correction beside that colour's
|
||||
* temperature it was drawn at 95 R☉, and Alp Cam, O9.5 Ia, at 338, where 14 and 21 are published.
|
||||
*
|
||||
* G to M giants take van Belle et al.'s (2021, ApJ 922, 163, table 8) interferometric scale, fitted
|
||||
* to 191 giants from G1 to M7.75 III: 4 797 K at G8, 4 388 at K2, 3 816 at M0, 3 472 at M4, and held
|
||||
* at 3 134 K from M6, where its own M6 and M7 giants average 3 112 and 3 114 K; carried on to M7.9
|
||||
* instead, the M6 giants were 3 300 K. O to F giants take the dwarf of their type, which a
|
||||
* supergiant of the same type is within a few per cent of from B8 on, and a few thousand kelvin
|
||||
* cooler than at B0 (Alnilam, B0 Ia, about 27 000 K against B0 V's 31 400). Carbon and S stars take {@link CARBON_STAR}.
|
||||
*/
|
||||
export function giantSurface(spectralType: string | null | undefined): GiantSurface | null {
|
||||
// ponytail: kept per type string, unbounded; the catalogue has 2 888 of them. The star field asks
|
||||
// for each of its 455 571 stars at boot, and the split and two regular expressions took 20-40 ms.
|
||||
if (!GIANT_SURFACES.has(spectralType)) {
|
||||
GIANT_SURFACES.set(spectralType, giantSurfaceOfType(spectralType));
|
||||
}
|
||||
return GIANT_SURFACES.get(spectralType)!;
|
||||
}
|
||||
|
||||
type GiantSurface = { temperatureK: number; bolometricCorrectionV: number };
|
||||
const GIANT_SURFACES = new Map<string | null | undefined, GiantSurface | null>();
|
||||
|
||||
function giantSurfaceOfType(spectralType: string | null | undefined): GiantSurface | null {
|
||||
if (!isGiant(spectralType)) {
|
||||
return null;
|
||||
}
|
||||
const primary = (spectralType ?? '').split('+')[0].trim();
|
||||
if (/^[CNRS]/.test(primary)) {
|
||||
return CARBON_STAR;
|
||||
}
|
||||
const parsed = parseSpectralClass(primary);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
const { spectralClass, subclass } = parsed;
|
||||
if (spectralClass === 'G' || spectralClass === 'K' || spectralClass === 'M') {
|
||||
// van Belle's index: G0 at 50, K0 at 60, K5 at 65 and M0 at 66, so a K later than K5 falls between.
|
||||
const index = spectralClass === 'G' ? 50 + subclass : spectralClass === 'K' ? 60 + Math.min(subclass, 5) + Math.max(subclass - 5, 0) / 5 : 66 + subclass;
|
||||
const temperatureK = index <= 61 ? 7856 - 52.74 * index : index <= 64 ? 16751 - 199.41 * index : index < 72 ? 9491 - 85.98 * index : 3134;
|
||||
return { temperatureK, bolometricCorrectionV: index < M_GIANT_CORRECTIONS[0][0] ? dwarfSequenceAtTemperature(temperatureK).bolometricCorrectionV : mGiantCorrection(index) };
|
||||
}
|
||||
const dwarf = dwarfSequenceAtType(primary)!;
|
||||
return { temperatureK: dwarf.temperatureK, bolometricCorrectionV: dwarf.bolometricCorrectionV };
|
||||
}
|
||||
|
||||
/**
|
||||
* An M giant's bolometric correction to V by van Belle's index: the median over each type of his own
|
||||
* giants, m_bol from their table 4 fluxes (IAU 2015 zero point) less their dereddened Johnson V from
|
||||
* table 6 — 18 at M0, 11 at M2, 11 at M3, 31 at M4, 15 at M5, 7 at M5.5, 3 at M6, and the 4 from M7
|
||||
* to M7.75 at their mean index. Linear between, held past the ends. Down to M3 it is within 0.1 of
|
||||
* the dwarf sequence's at the same temperature; past it TiO takes the V light and the two part, by
|
||||
* 0.4 at M4, 1.2 at M6 and 2.1 at M7. Taken from the dwarfs, RZ Ari (M6 III) came out 81.5 R☉ against
|
||||
* the 119 its 10.3 mas give at its distance, and EU Del 72.6 against 126.
|
||||
*/
|
||||
const M_GIANT_CORRECTIONS: readonly (readonly [number, number])[] = [
|
||||
[66, -1.22],
|
||||
[68, -1.5],
|
||||
[69, -1.73],
|
||||
[70, -2.2],
|
||||
[71, -2.68],
|
||||
[71.5, -3.34],
|
||||
[72, -3.94],
|
||||
[73.5, -4.86]
|
||||
];
|
||||
|
||||
function mGiantCorrection(index: number): number {
|
||||
const next = M_GIANT_CORRECTIONS.findIndex(([at]) => at >= index);
|
||||
if (next <= 0) {
|
||||
return M_GIANT_CORRECTIONS[next === 0 ? 0 : M_GIANT_CORRECTIONS.length - 1][1];
|
||||
}
|
||||
const [[fromIndex, from], [toIndex, to]] = [M_GIANT_CORRECTIONS[next - 1], M_GIANT_CORRECTIONS[next]];
|
||||
return from + ((to - from) * (index - fromIndex)) / (toIndex - fromIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* A carbon or S star's temperature and bolometric correction to V: the medians of Bergeat, Knapik &
|
||||
* Rutily (2001, A&A 369, 178) over the 441 carbon stars of their table 10, and over the 383 of
|
||||
* those with a V magnitude. No type in the table reads for them, and they were given the Sun's
|
||||
* −0.06 at the M8.5 dwarf's 2 420 K: La Superba came out 544 L☉ and 133 R☉, where Bergeat's own
|
||||
* figures give 8 090 L☉ at the same distance and McDonald et al. (2017) 315 R☉. S stars, between M
|
||||
* and C, are given the carbon stars' figures for want of their own.
|
||||
*/
|
||||
const CARBON_STAR = { temperatureK: 2990, bolometricCorrectionV: -2.83 } as const;
|
||||
|
||||
/**
|
||||
* Radius in solar radii from luminosity and temperature — Stefan-Boltzmann, L = 4πR²σT⁴, in solar
|
||||
* units. Luminosity-class blind, since the luminosity comes from the distance: a giant comes out a
|
||||
* giant whatever the sequence took it for.
|
||||
*/
|
||||
export function radiusFromLuminositySolar(luminositySolar: number, temperatureK: number): number {
|
||||
return Math.sqrt(luminositySolar) / (temperatureK / SOLAR_EFFECTIVE_TEMPERATURE_K) ** 2;
|
||||
}
|
||||
|
||||
/** The range Kim et al.'s fit to the Planckian locus covers; a temperature outside it is clamped. */
|
||||
const PLANCKIAN_LOCUS_MIN_K = 1667;
|
||||
const PLANCKIAN_LOCUS_MAX_K = 25000;
|
||||
|
||||
/**
|
||||
* The colour of a blackbody at `temperatureK`, in linear sRGB with its brightest channel at 1:
|
||||
* its chromaticity off the Planckian locus (Kim et al. 2002, the cubic fit to CIE 1931), then
|
||||
* CIE XYZ to sRGB. Against the display's own white, D65, unless `whitePointK` names the blackbody
|
||||
* that is to read as white — as the Sun's does for the photographs of its planets, which were
|
||||
* taken in its light.
|
||||
*
|
||||
* At D65, a 2 900 K M dwarf is sRGB (255, 180, 103), the Sun (255, 241, 234), a 9 600 K A star
|
||||
* (208, 219, 255): Charity's table, which integrates the Planck spectrum, gives (255, 182, 98),
|
||||
* (255, 241, 231) at 5 800 K and (211, 221, 255).
|
||||
*/
|
||||
export function blackbodyColor(temperatureK: number, whitePointK?: number): [number, number, number] {
|
||||
const rgb = blackbodyLinearSrgb(temperatureK);
|
||||
const white = whitePointK === undefined ? [1, 1, 1] : blackbodyLinearSrgb(whitePointK);
|
||||
const relative = rgb.map((channel, i) => channel / white[i]);
|
||||
const brightest = Math.max(...relative);
|
||||
return relative.map((channel) => channel / brightest) as [number, number, number];
|
||||
}
|
||||
|
||||
function blackbodyLinearSrgb(temperatureK: number): number[] {
|
||||
const t = 1000 / Math.min(Math.max(temperatureK, PLANCKIAN_LOCUS_MIN_K), PLANCKIAN_LOCUS_MAX_K);
|
||||
const x =
|
||||
t >= 0.25 ? -0.2661239 * t ** 3 - 0.2343589 * t ** 2 + 0.8776956 * t + 0.17991 : -3.0258469 * t ** 3 + 2.1070379 * t ** 2 + 0.2226347 * t + 0.24039;
|
||||
const y =
|
||||
t >= 1000 / 2222
|
||||
? -1.1063814 * x ** 3 - 1.3481102 * x ** 2 + 2.18555832 * x - 0.20219683
|
||||
: t >= 0.25
|
||||
? -0.9549476 * x ** 3 - 1.37418593 * x ** 2 + 2.09137015 * x - 0.16748867
|
||||
: 3.081758 * x ** 3 - 5.8733867 * x ** 2 + 3.75112997 * x - 0.37001483;
|
||||
const [X, Y, Z] = [x / y, 1, (1 - x - y) / y];
|
||||
// Below 1 920 K the locus leaves the sRGB gamut, and blue comes out negative.
|
||||
return [3.2406 * X - 1.5372 * Y - 0.4986 * Z, -0.9689 * X + 1.8758 * Y + 0.0415 * Z, 0.0557 * X - 0.204 * Y + 1.057 * Z].map((channel) => Math.max(channel, 0));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { formatAu, formatDensity, formatLuminosity, formatMassEarth, formatParsecs, formatPeriod, formatRadiusKm, formatTemperature } from './quantity';
|
||||
import { formatAu, formatDensity, formatDistance, formatLuminosity, formatMassEarth, formatParsecs, formatPeriod, formatRadiusKm, formatTemperature } from './quantity';
|
||||
|
||||
describe('formatParsecs', () => {
|
||||
it('switches to kiloparsecs past a thousand parsecs', () => {
|
||||
@@ -13,6 +13,33 @@ describe('formatParsecs', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDistance', () => {
|
||||
it('gives the error to the digits the distance is shown to', () => {
|
||||
expect(formatDistance(117.3, 0.1)).toBe('117 ± 12 pc');
|
||||
expect(formatDistance(4.2, 0.05)).toBe('4.20 ± 0.21 pc');
|
||||
expect(formatDistance(1830, 0.15)).toBe('1.8 ± 0.3 kpc');
|
||||
});
|
||||
|
||||
it('leaves off an error of a per cent or less, or one too small to show', () => {
|
||||
expect(formatDistance(117.3, 0.01)).toBe('117 pc');
|
||||
expect(formatDistance(12, 0.03)).toBe('12 pc');
|
||||
expect(formatDistance(117.3, undefined)).toBe('117 pc');
|
||||
});
|
||||
|
||||
it("gives a range once the error is a fifth of the parallax, and no upper end past all of it", () => {
|
||||
// Alnilam: 1.65 ± 0.45 mas in Hipparcos.
|
||||
expect(formatDistance(606.06, 0.45 / 1.65)).toBe('476 pc to 833 pc');
|
||||
expect(formatDistance(250, 1)).toBe('125 pc or more');
|
||||
});
|
||||
|
||||
it('keeps an error on the distance itself symmetric, however large', () => {
|
||||
// AT2021ueyL: 1 040 +740 −440 pc in the archive, whose mean is 57 % of it.
|
||||
expect(formatDistance(1040, 0.567, true)).toBe('1.0 ± 0.6 kpc');
|
||||
expect(formatDistance(134, 0.319, true)).toBe('134 ± 43 pc');
|
||||
expect(formatDistance(134, 0.005, true)).toBe('134 pc');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatAu', () => {
|
||||
it('holds four decimals for close-in orbits', () => {
|
||||
// 0.0026 AU is a real published semi-major axis; two decimals would render it — and every
|
||||
@@ -76,7 +103,11 @@ describe('formatTemperature and formatDensity', () => {
|
||||
describe('formatLuminosity', () => {
|
||||
it('stays decimal across the ordinary range', () => {
|
||||
expect(formatLuminosity(1)).toBe('1.00 L☉');
|
||||
expect(formatLuminosity(0.0017)).toBe('0.002 L☉');
|
||||
expect(formatLuminosity(0.0017)).toBe('0.0017 L☉');
|
||||
// Proxima's archive figure, which three decimals read as 0.002, a third over.
|
||||
expect(formatLuminosity(0.0015100106)).toBe('0.0015 L☉');
|
||||
expect(formatLuminosity(0.0523)).toBe('0.052 L☉');
|
||||
expect(formatLuminosity(0.523)).toBe('0.523 L☉');
|
||||
});
|
||||
|
||||
it('goes to powers of ten at the extremes', () => {
|
||||
|
||||
@@ -9,7 +9,41 @@
|
||||
|
||||
/** Distance in parsecs, switching to kiloparsecs where the number would otherwise run long. */
|
||||
export function formatParsecs(distancePc: number): string {
|
||||
return distancePc >= 1000 ? `${(distancePc / 1000).toFixed(1)} kpc` : `${distancePc.toFixed(distancePc < 10 ? 2 : 0)} pc`;
|
||||
const { divisor, digits, unit } = parsecScale(distancePc);
|
||||
return `${(distancePc / divisor).toFixed(digits)} ${unit}`;
|
||||
}
|
||||
|
||||
function parsecScale(distancePc: number): { divisor: number; digits: number; unit: string } {
|
||||
return distancePc >= 1000 ? { divisor: 1000, digits: 1, unit: 'kpc' } : { divisor: 1, digits: distancePc < 10 ? 2 : 0, unit: 'pc' };
|
||||
}
|
||||
|
||||
/**
|
||||
* A star's distance with its uncertainty, given as a fraction of it: `117 ± 12 pc`, to the
|
||||
* digits the distance itself is shown to. Left off where it is 1 % or less, or would round to
|
||||
* nothing at those digits, since the figure is then already as good as it reads.
|
||||
*
|
||||
* Past a fifth, a range: a distance inverted from a parallax takes the parallax's symmetric error
|
||||
* bar as a lopsided one — Alnilam's 1.65 ± 0.45 mas is 476 to 833 pc, not 606 ± 165. Only a
|
||||
* Hipparcos distance gets there; Gaia's query stops at a fifth. An error as large as the parallax
|
||||
* leaves no upper bound at all.
|
||||
*
|
||||
* Not so where the error is on the distance itself, `onDistance`: the Exoplanet Archive's
|
||||
* sy_disterr1 and 2, one-sided errors in parsecs, of which the catalogue keeps the mean. Many of
|
||||
* those distances are no parallax at all — KMT-2016-BLG-1836L's 7.1 kpc comes from a lensing model
|
||||
* — and read as one they gave 129 cards a range the archive does not: 5.8 to 9.2 kpc there, where
|
||||
* it publishes 7 100 +800 −2 400 pc. They keep the ± at any size.
|
||||
*/
|
||||
export function formatDistance(distancePc: number, relativeError: number | undefined, onDistance = false): string {
|
||||
if (relativeError === undefined || relativeError <= 0.01) {
|
||||
return formatParsecs(distancePc);
|
||||
}
|
||||
if (relativeError < 0.2 || onDistance) {
|
||||
const { divisor, digits, unit } = parsecScale(distancePc);
|
||||
const error = ((distancePc * relativeError) / divisor).toFixed(digits);
|
||||
return Number(error) === 0 ? formatParsecs(distancePc) : `${(distancePc / divisor).toFixed(digits)} ± ${error} ${unit}`;
|
||||
}
|
||||
const nearest = formatParsecs(distancePc / (1 + relativeError));
|
||||
return relativeError >= 1 ? `${nearest} or more` : `${nearest} to ${formatParsecs(distancePc / (1 - relativeError))}`;
|
||||
}
|
||||
|
||||
/** Distance in astronomical units, for anything inside a system. */
|
||||
@@ -62,13 +96,18 @@ export function formatDensity(gramsPerCm3: number): string {
|
||||
return `${gramsPerCm3.toFixed(2)} g/cm³`;
|
||||
}
|
||||
|
||||
/** Bolometric luminosity in solar units, which spans many orders of magnitude. */
|
||||
/**
|
||||
* Bolometric luminosity in solar units, which spans many orders of magnitude. Two figures below a
|
||||
* hundredth, as in the ×10ⁿ form below a thousandth: three decimals left one there, and Proxima's
|
||||
* archive luminosity, 1.51×10⁻³ L☉, read 0.002, a third over; 23 of the 4 440 hosts the archive
|
||||
* gives one for read more than 10 % off it.
|
||||
*/
|
||||
export function formatLuminosity(solar: number): string {
|
||||
if (solar >= 1000 || (solar > 0 && solar < 0.001)) {
|
||||
const exponent = Math.floor(Math.log10(solar));
|
||||
return `${(solar / Math.pow(10, exponent)).toFixed(1)}×10${superscript(exponent)} L☉`;
|
||||
}
|
||||
return `${solar.toFixed(solar < 1 ? 3 : 2)} L☉`;
|
||||
return `${solar < 0.01 ? solar.toPrecision(2) : solar.toFixed(solar < 1 ? 3 : 2)} L☉`;
|
||||
}
|
||||
|
||||
function superscript(value: number): string {
|
||||
|
||||
@@ -2,11 +2,16 @@ import { OrbitalElements } from './body.model';
|
||||
|
||||
/**
|
||||
* A confirmed exoplanet from the NASA Exoplanet Archive (`Planetary Systems` TAP table),
|
||||
* cross-referenced to its host star in the HYG index where possible.
|
||||
* cross-referenced to its host star in the star catalogue.
|
||||
*/
|
||||
export interface ExoplanetRecord {
|
||||
id: string;
|
||||
hostStarId: number | null; // null if the host star could not be cross-referenced to HYG
|
||||
/**
|
||||
* The host's star-catalogue id: a HYG or Gaia star it was matched to, or else a star the ETL
|
||||
* added from the archive's own figures. Null only when the archive gives no position and
|
||||
* distance to place one with.
|
||||
*/
|
||||
hostStarId: number | null;
|
||||
hostStarName: string;
|
||||
name: string;
|
||||
radiusEarth?: number;
|
||||
@@ -25,6 +30,16 @@ export interface ExoplanetRecord {
|
||||
periodDays?: number;
|
||||
/** Host star mass in solar masses (`st_mass`); the fallback when no period is published. */
|
||||
hostStarMassSolar?: number;
|
||||
/**
|
||||
* The host's radius in solar radii (`st_rad`), effective temperature in kelvin (`st_teff`) and
|
||||
* luminosity in solar luminosities (10^`st_lum`), where the archive gives them: from the
|
||||
* composite table (pscomppars) alone, since the default-row query does not ask for these three,
|
||||
* and each column of it may come from a different reference — Proxima's 0.141 R☉ is one.
|
||||
* Preferred to what `stellar.ts` would derive; see `starSurfaceOf`.
|
||||
*/
|
||||
hostStarRadiusSolar?: number;
|
||||
hostStarTemperatureK?: number;
|
||||
hostStarLuminositySolar?: number;
|
||||
/**
|
||||
* The host star's own published astrometry (`ra`, `dec`, `sy_dist`, `sy_pmra`, `sy_pmdec`) —
|
||||
* everything the cross-reference above was resolved from.
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { BYTES_PER_STAR_META, BYTES_PER_STAR_POSITION, decodeStarCatalog, encodeStarCatalog, isDesignation } from './star-catalog';
|
||||
import { StarRecord } from './star.model';
|
||||
import { formatDistance } from '../format/quantity';
|
||||
|
||||
const STARS: StarRecord[] = [
|
||||
{ id: 0, name: 'Sol', x: 0, y: 0, z: 0, magnitude: -26.7, spectralType: 'G2V', colorIndex: 0.656 },
|
||||
@@ -97,6 +98,50 @@ describe('encodeStarCatalog / decodeStarCatalog', () => {
|
||||
});
|
||||
|
||||
|
||||
describe('the photometry and distance error columns', () => {
|
||||
const MEASURED: StarRecord[] = [
|
||||
{ id: 1, name: 'Sirius', x: 1, y: 0, z: 0, magnitude: -1.44, magnitudeBand: 'V', spectralType: 'A0m', colorIndex: 0.009, colorSystem: 'B-V', distanceError: 0.0036, source: 'hyg' },
|
||||
{ id: 2, name: 'Gaia DR3 2', x: 0, y: 117, z: 0, magnitude: 11.2, magnitudeBand: 'G', spectralType: 'Unknown', colorIndex: 1.43, colorSystem: 'BP-RP', distanceError: 0.199, distanceFromGaia: true, source: 'gaia' },
|
||||
// A HYG star at Gaia's distance, and one no survey gave a magnitude, a colour or an error.
|
||||
{ id: 3, name: 'HD 3', x: 0, y: 0, z: 300, magnitude: 7, magnitudeBand: 'V', spectralType: 'K0', colorIndex: 1.0, colorSystem: 'B-V', distanceError: 1e-12, distanceFromGaia: true, source: 'hyg' },
|
||||
{ id: 4, name: 'Gaia DR3 4', x: 5, y: 5, z: 0, magnitude: 12, spectralType: 'Unknown', colorIndex: null, distanceFromGaia: true, source: 'gaia' },
|
||||
// A Hipparcos parallax smaller than its own error.
|
||||
{ id: 5, name: 'HIP 5', x: 0, y: 200, z: 0, magnitude: 6, magnitudeBand: 'V', spectralType: 'B8', colorIndex: -0.1, colorSystem: 'B-V', distanceError: 1.4, source: 'hyg' },
|
||||
// An archive host whose B−V is its temperature's.
|
||||
{ id: 6, name: 'Kepler-445', x: 0, y: 0, z: 90, magnitude: 17.6, magnitudeBand: 'G', spectralType: 'M4', colorIndex: 1.66, colorSystem: 'B-V', colorFromTemperature: true, source: 'exoplanet-archive' }
|
||||
];
|
||||
const encoded = encodeStarCatalog(MEASURED);
|
||||
const decoded = decodeStarCatalog(encoded.index, encoded.positions, encoded.meta);
|
||||
|
||||
it('carries the band, which colour the colour index is, and whose parallax the distance is', () => {
|
||||
expect(decoded.map((star) => star.magnitudeBand)).toEqual(['V', 'G', 'V', undefined, 'V', 'G']);
|
||||
expect(decoded.map((star) => star.colorSystem)).toEqual(['B-V', 'BP-RP', 'B-V', undefined, 'B-V', 'B-V']);
|
||||
expect(decoded.map((star) => star.distanceFromGaia)).toEqual([false, true, true, true, false, false]);
|
||||
expect(decoded.map((star) => star.colorFromTemperature)).toEqual([false, false, false, false, false, true]);
|
||||
});
|
||||
|
||||
it('keeps a distance error to within a step at both ends of its range, and none as none', () => {
|
||||
// A step is 0.05 % of distance at Sirius's 0.36 %, and 0.35 % at the 20 % Gaia's cut allows.
|
||||
expect(Math.abs(decoded[0].distanceError! - 0.0036)).toBeLessThan(0.0003);
|
||||
expect(Math.abs(decoded[1].distanceError! - 0.199)).toBeLessThan(0.002);
|
||||
// Too small to round to a step — √(10⁻¹²) is 0.07 of one in 65 535 — but published, so not read
|
||||
// back as unpublished. 10⁻⁶ was, at 255 steps; at 65 535 it rounds to 66 and never needed the floor.
|
||||
expect(decoded[2].distanceError).toBeGreaterThan(0);
|
||||
expect(decoded[3].distanceError).toBeUndefined();
|
||||
// Past the parallax itself there is no upper bound on the distance, which is what 100 % says.
|
||||
expect(decoded[4].distanceError).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps an error close enough that the card prints the published one', () => {
|
||||
// Rigel, 264.55 pc at van Leeuwen's 3.78 ± 0.34 mas: 23.8 pc, which one byte stored as 23.5.
|
||||
const rigel: StarRecord = { id: 7, name: 'Rigel', x: 264.55, y: 0, z: 0, magnitude: 0.18, magnitudeBand: 'V', spectralType: 'B8Ia', colorIndex: -0.03, colorSystem: 'B-V', distanceError: 0.34 / 3.78, source: 'hyg' };
|
||||
const packed = encodeStarCatalog([rigel]);
|
||||
const [kept] = decodeStarCatalog(packed.index, packed.positions, packed.meta);
|
||||
expect(formatDistance(264.55, kept.distanceError)).toBe('265 ± 24 pc');
|
||||
expect(Math.abs(kept.distanceError! / rigel.distanceError! - 1)).toBeLessThan(1e-4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('star catalogue provenance and derived names', () => {
|
||||
const MIXED: StarRecord[] = [
|
||||
{ id: 5, name: 'Sirius', x: 1, y: 0, z: 0, magnitude: -1.4, spectralType: 'A0', colorIndex: 0.0, source: 'hyg' },
|
||||
|
||||
@@ -26,13 +26,43 @@ export const STAR_POSITION_COMPONENTS = 3;
|
||||
export const BYTES_PER_STAR_POSITION = STAR_POSITION_COMPONENTS * Float32Array.BYTES_PER_ELEMENT;
|
||||
|
||||
/**
|
||||
* Columns in `stars-meta.bin`, in order: catalogue id, apparent magnitude, colour index, and an
|
||||
* index into the spectral-type dictionary. Stored column by column rather than record by record
|
||||
* so each one is a single typed-array view over the buffer, with no per-record stride or
|
||||
* alignment padding.
|
||||
* Columns in `stars-meta.bin`, in order: catalogue id, apparent magnitude, colour index, an
|
||||
* index into the spectral-type dictionary, the distance's relative error (see
|
||||
* {@link DISTANCE_ERROR_STEPS}), and what those were measured in (see {@link PHOTOMETRY}). Stored column by column
|
||||
* rather than record by record so each one is a single typed-array view over the buffer, with no
|
||||
* per-record stride or alignment padding.
|
||||
*/
|
||||
export const BYTES_PER_STAR_META =
|
||||
Int32Array.BYTES_PER_ELEMENT + Float32Array.BYTES_PER_ELEMENT + Float32Array.BYTES_PER_ELEMENT + Uint16Array.BYTES_PER_ELEMENT;
|
||||
Int32Array.BYTES_PER_ELEMENT +
|
||||
Float32Array.BYTES_PER_ELEMENT +
|
||||
Float32Array.BYTES_PER_ELEMENT +
|
||||
Uint16Array.BYTES_PER_ELEMENT +
|
||||
Uint16Array.BYTES_PER_ELEMENT +
|
||||
Uint8Array.BYTES_PER_ELEMENT;
|
||||
|
||||
/**
|
||||
* Bits of the photometry column. The band takes two: none (a stand-in magnitude), V or G. None
|
||||
* of it follows from the source, which records where the *position* came from: 62 097 stars
|
||||
* Gaia places keep HYG's V and B−V, and the archive's stars in G have a B−V from their
|
||||
* temperature. The next bit says whose parallax the distance is, which for a HYG star Gaia did
|
||||
* not place can still be Gaia's, and the last whether the colour was read off a temperature.
|
||||
*/
|
||||
const PHOTOMETRY = { bandV: 1, bandG: 2, bandMask: 3, colorBpRp: 4, distanceFromGaia: 8, colorFromTemperature: 16 } as const;
|
||||
|
||||
/**
|
||||
* The distance error column holds the square root of the relative error, in 65 535ths, and 0 where
|
||||
* none was published. The errors span three orders of magnitude — Gaia's are a median 0.3 % and
|
||||
* at most 20 %, the cut its queries make, while a Hipparcos parallax the map keeps for a bright
|
||||
* star can be as large as itself — and the square root keeps a step small at each end. Anything
|
||||
* past 100 % is stored as that, where it no longer bounds the distance from above.
|
||||
*
|
||||
* In 255ths, one byte, a step was 0.35 % of the distance at a 20 % error, a few per cent of the
|
||||
* error itself, and the card printed another error than the published one for 2 822 of the 53 209
|
||||
* Gaia stars it prints one for; Rigel read ± 23 pc where van Leeuwen's 3.78 ± 0.34 mas gives 24.
|
||||
* In two bytes, placed before the photometry byte so the column stays aligned for its view, 12 do,
|
||||
* each on a rounding half.
|
||||
*/
|
||||
const DISTANCE_ERROR_STEPS = 65_535;
|
||||
|
||||
/** `stars-index.json`: everything that is a string, plus the count the columns are sized by. */
|
||||
export interface StarCatalogIndex {
|
||||
@@ -81,6 +111,8 @@ interface StarMetaColumns {
|
||||
magnitudes: Float32Array;
|
||||
colorIndices: Float32Array;
|
||||
spectralTypeIndices: Uint16Array;
|
||||
photometry: Uint8Array;
|
||||
distanceErrors: Uint16Array;
|
||||
}
|
||||
|
||||
/** Lays typed-array views over the meta buffer at the offsets the format defines. */
|
||||
@@ -93,8 +125,12 @@ function metaColumns(buffer: ArrayBuffer, count: number): StarMetaColumns {
|
||||
const colorIndices = new Float32Array(buffer, offset, count);
|
||||
offset += count * Float32Array.BYTES_PER_ELEMENT;
|
||||
const spectralTypeIndices = new Uint16Array(buffer, offset, count);
|
||||
offset += count * Uint16Array.BYTES_PER_ELEMENT;
|
||||
const distanceErrors = new Uint16Array(buffer, offset, count);
|
||||
offset += count * Uint16Array.BYTES_PER_ELEMENT;
|
||||
const photometry = new Uint8Array(buffer, offset, count);
|
||||
|
||||
return { ids, magnitudes, colorIndices, spectralTypeIndices };
|
||||
return { ids, magnitudes, colorIndices, spectralTypeIndices, photometry, distanceErrors };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -152,6 +188,14 @@ export function encodeStarCatalog(stars: readonly StarRecord[]): {
|
||||
columns.magnitudes[index] = star.magnitude;
|
||||
columns.colorIndices[index] = star.colorIndex ?? Number.NaN;
|
||||
columns.spectralTypeIndices[index] = spectralTypeId;
|
||||
columns.photometry[index] =
|
||||
(star.magnitudeBand === 'V' ? PHOTOMETRY.bandV : star.magnitudeBand === 'G' ? PHOTOMETRY.bandG : 0) |
|
||||
(star.colorSystem === 'BP-RP' ? PHOTOMETRY.colorBpRp : 0) |
|
||||
(star.distanceFromGaia ? PHOTOMETRY.distanceFromGaia : 0) |
|
||||
(star.colorFromTemperature ? PHOTOMETRY.colorFromTemperature : 0);
|
||||
// At least one step, so an error too small to round to one is not read back as none published.
|
||||
columns.distanceErrors[index] =
|
||||
star.distanceError === undefined ? 0 : Math.max(1, Math.round(Math.sqrt(Math.min(1, star.distanceError)) * DISTANCE_ERROR_STEPS));
|
||||
});
|
||||
|
||||
// A per-star column is only worth writing when the stars actually differ.
|
||||
@@ -186,6 +230,9 @@ export function decodeStarCatalog(index: StarCatalogIndex, positions: Float32Arr
|
||||
const id = columns.ids[i];
|
||||
const sourceIndex = index.sourceIndices.length > 0 ? index.sourceIndices[i] : index.sources.length === 1 ? 0 : -1;
|
||||
const source = index.sources[sourceIndex];
|
||||
const photometry = columns.photometry[i];
|
||||
const band = photometry & PHOTOMETRY.bandMask;
|
||||
const distanceError = columns.distanceErrors[i];
|
||||
|
||||
stars[i] = {
|
||||
id,
|
||||
@@ -196,6 +243,12 @@ export function decodeStarCatalog(index: StarCatalogIndex, positions: Float32Arr
|
||||
magnitude: columns.magnitudes[i],
|
||||
spectralType: index.spectralTypes[columns.spectralTypeIndices[i]],
|
||||
colorIndex: Number.isNaN(colorIndex) ? null : colorIndex,
|
||||
// Set on every record, if only to undefined, so that all of them have the one shape.
|
||||
magnitudeBand: band === PHOTOMETRY.bandV ? 'V' : band === PHOTOMETRY.bandG ? 'G' : undefined,
|
||||
colorSystem: Number.isNaN(colorIndex) ? undefined : photometry & PHOTOMETRY.colorBpRp ? 'BP-RP' : 'B-V',
|
||||
distanceError: distanceError === 0 ? undefined : (distanceError / DISTANCE_ERROR_STEPS) ** 2,
|
||||
distanceFromGaia: (photometry & PHOTOMETRY.distanceFromGaia) !== 0,
|
||||
colorFromTemperature: (photometry & PHOTOMETRY.colorFromTemperature) !== 0,
|
||||
...(source ? { source: source.id } : {})
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* A single star from the HYG (Hipparcos/Yale/Gliese) catalog, positioned relative to the
|
||||
* A single star from Gaia DR3, HYG (Hipparcos/Yale/Gliese) or the NASA Exoplanet Archive — see
|
||||
* `source` — positioned relative to the
|
||||
* Sun in the galaxy-scale coordinate system (parsecs). The same positions are also packed
|
||||
* into a compact binary buffer (`stars.bin`, in index order) for fast bulk rendering; this
|
||||
* record format (`stars-index.json`) is used for search, labels, and lookups by id/name.
|
||||
@@ -13,19 +14,58 @@ export interface StarRecord {
|
||||
magnitude: number;
|
||||
spectralType: string;
|
||||
/**
|
||||
* B-V colour index, or `null` where the catalog has no photometry — about 10% of stars
|
||||
* within the distance cutoff. Deliberately nullable rather than defaulted: `0` is a real,
|
||||
* Colour index — B-V, or Gaia's BP-RP where `colorSystem` says so — or `null` where the
|
||||
* catalog has no photometry. Deliberately nullable rather than defaulted: `0` is a real,
|
||||
* meaningful colour index (a hot blue-white A-type star), so using it to stand for "unknown"
|
||||
* silently mis-colours those stars. Consumers resolve the gap from `spectralType`; see
|
||||
* `colorIndexToRgb`.
|
||||
*/
|
||||
colorIndex: number | null;
|
||||
/**
|
||||
* The band `magnitude` was measured in: Johnson V (HYG, and the archive where it has one) or
|
||||
* Gaia's G, which for a red dwarf reads up to three magnitudes brighter than V. Absent where no
|
||||
* survey measured the star and `magnitude` is the ETL's stand-in.
|
||||
*/
|
||||
magnitudeBand?: 'V' | 'G';
|
||||
/**
|
||||
* Which colour `colorIndex` is: Johnson B−V, or Gaia's BP−RP, which is larger for the same star
|
||||
* — 0.82 against 0.65 for a G2 dwarf like the Sun, 3.35 against 1.83 for an M5 dwarf (Pecaut &
|
||||
* Mamajek). Absent with it.
|
||||
*/
|
||||
colorSystem?: 'B-V' | 'BP-RP';
|
||||
/**
|
||||
* Whether `colorIndex` was read off the dwarf sequence at the star's effective temperature rather
|
||||
* than measured: 54 archive-placed hosts with a temperature and no B or V magnitude. Three more,
|
||||
* whose temperatures are outside the table, have no colour at all.
|
||||
*/
|
||||
colorFromTemperature?: boolean;
|
||||
/**
|
||||
* Relative uncertainty of the distance, σd/d: the relative error of the parallax it was
|
||||
* inverted from, which to first order is the same — or, for a star the Exoplanet Archive places,
|
||||
* the mean of the two one-sided errors it gives on the distance itself, which is often no
|
||||
* parallax's. Absent where none was published.
|
||||
*/
|
||||
distanceError?: number;
|
||||
/** Whether the distance is Gaia DR3's parallax, whichever catalogue describes the star. */
|
||||
distanceFromGaia?: boolean;
|
||||
/**
|
||||
* Which catalogue this star's position came from, once more than one contributes. Absent for a
|
||||
* single-source build; see `star-merge.ts`, where overlapping catalogues are reconciled and
|
||||
* the better-measured parallax wins.
|
||||
*/
|
||||
source?: string;
|
||||
/**
|
||||
* Proper motion in milliarcseconds a year, in right ascension (times cos δ) and declination,
|
||||
* where the source measured one. Only the ETL sets these, for `star-merge.ts` to recognise two
|
||||
* entries of one star whose positions disagree; the assets do not carry them.
|
||||
*/
|
||||
pmRaMasYr?: number;
|
||||
pmDecMasYr?: number;
|
||||
/**
|
||||
* The Gaia DR3 source a Gaia entry is, kept once a HYG row has named it: what `foldByIdentity`
|
||||
* looks up a star SIMBAD identifies by. Only the ETL sets it; the assets do not carry it.
|
||||
*/
|
||||
gaiaDesignation?: string;
|
||||
}
|
||||
|
||||
/** HYG id used for the Sun itself, so solar-system bodies can reference their host star. */
|
||||
|
||||
@@ -54,7 +54,16 @@ const BODY_TEXTURE_PATHS: Record<string, string> = {
|
||||
charon: 'assets/textures/bodies/charon.jpg'
|
||||
};
|
||||
|
||||
/** The Sun isn't a `BodyRecord` (it's the system's star marker), so it's looked up separately. */
|
||||
/**
|
||||
* The Sun's surface, which every star's disc in the system view is drawn with, tinted to its own
|
||||
* colour. Baked from the pack's 2048 by 1024 map (822 427 bytes) to 1024 by 512 in grey (31 306):
|
||||
* the Sun reaches 216 px across at its closest approach on a 1080-line screen, and a sphere shows
|
||||
* π times its diameter of the map round its equator, so this covers it to a 1440-line one. The
|
||||
* tint supplies the colour. The map's brightness varied by 56 % rms, a mottled rock rather than a
|
||||
* star; it is rescaled to 14 % rms about the display's white, of the order of the Sun's own
|
||||
* granulation contrast, and the brighter half clipped there as in a photograph exposed for the
|
||||
* disc, which leaves 6 %. Not a `BodyRecord`, so it is looked up separately.
|
||||
*/
|
||||
export const SUN_TEXTURE_PATH = 'assets/textures/bodies/sun.jpg';
|
||||
export const SATURN_RING_TEXTURE_PATH = 'assets/textures/bodies/saturn_ring.png';
|
||||
export const MILKY_WAY_SKYBOX_PATH = 'assets/textures/skybox/milkyway.jpg';
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 803 KiB After Width: | Height: | Size: 31 KiB |
+363
-23
@@ -12,8 +12,11 @@ import { fetchExoplanets } from './fetchExoplanets';
|
||||
import { fetchSolarSystem, FREELY_SPINNING_MOONS, offsetFromTrackDeg } from './fetchSolarSystem';
|
||||
import { TrackPoint } from './lib/horizons';
|
||||
import { subPlanetLongitudeDeg } from './lib/locked-spin';
|
||||
import { BYTES_PER_STAR_META, BYTES_PER_STAR_POSITION, decodeStarCatalog, encodeStarCatalog } from '../../src/app/shared/models/star-catalog';
|
||||
import { fetchStars } from './fetchStars';
|
||||
import { BYTES_PER_STAR_META, BYTES_PER_STAR_POSITION, decodeStarCatalog, encodeStarCatalog, isDesignation } from '../../src/app/shared/models/star-catalog';
|
||||
import { fetchStars, glieseGaiaDesignations } from './fetchStars';
|
||||
import { ARCHIVE_EPOCH, archiveStarId, CATALOGUE_EPOCH } from '../../src/app/shared/astro/host-star-matching';
|
||||
import { foldsInto } from '../../src/app/shared/astro/star-merge';
|
||||
import { propagateProperMotion, raDegDecDistanceToXyz } from '../../src/app/shared/astro/coordinates';
|
||||
import { describeSources } from './sources/registry';
|
||||
import { dataPath } from './lib/paths';
|
||||
|
||||
@@ -25,12 +28,91 @@ function assertCondition(condition: boolean, message: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stars whose magnitude is a stand-in, and distances published without an error. Measured 309 and
|
||||
* 332: the 44 Gaia sources with no G and the 265 archive hosts with neither V nor G; the 250 Gliese
|
||||
* distances (no Hipparcos parallax behind them) and 82 archive hosts the archive gives no error
|
||||
* for. Losing either field loses it for hundreds of thousands of stars.
|
||||
*/
|
||||
const MAX_STARS_WITHOUT_BAND = 1_000;
|
||||
const MAX_STARS_WITHOUT_DISTANCE_ERROR = 1_000;
|
||||
/**
|
||||
* What the errors themselves come to, which the two counts above cannot see: Gaia's parallax_error
|
||||
* stored in milliarcseconds rather than over the parallax still encodes, decodes and passes both.
|
||||
* Measured: the median relative error of the stars at Gaia's distance is 0.33 %, and 1 116 parallax
|
||||
* distances (0.24 % of the stars) have one of a fifth or more, which the card prints as a range.
|
||||
* The mistake above gives 1.92 % and 17 689.
|
||||
*/
|
||||
const MAX_MEDIAN_GAIA_DISTANCE_ERROR = 0.01;
|
||||
const MAX_RANGED_DISTANCE_SHARE = 0.01;
|
||||
/**
|
||||
* HYG stars that keep their own row but sit at Gaia's distance, which fetchStars flags for the card
|
||||
* to say "HYG, Gaia DR3 distance". Measured 8 105; the flag dropped leaves none, and nothing else
|
||||
* notices — the other 61 713 carry it from their Gaia row.
|
||||
*/
|
||||
const MIN_HYG_STARS_AT_GAIA_DISTANCE = 6_000;
|
||||
/**
|
||||
* The other half of placing a star at its more precise distance (8c3a860): a HYG star folded into
|
||||
* its Gaia entry that keeps its Hipparcos distance, which Gaia saturates on. Measured 384, none
|
||||
* before 8c3a860, and 148 with fetchStars handing combine Gaia's error with the Hipparcos distance,
|
||||
* where the two errors tie and Gaia's distance wins — which the floor above cannot see, since that
|
||||
* raises its count to 8 129. The two stars below are what that looks like: Tarazed went back to
|
||||
* Gaia's 178.9 pc, and Eta Leo kept its 389 pc but read "±17 %, HYG, Gaia DR3 distance".
|
||||
*/
|
||||
const MIN_GAIA_STARS_AT_HIPPARCOS_DISTANCE = 300;
|
||||
const HIPPARCOS_PLACED_STARS = [
|
||||
{ id: 96970, name: 'Tarazed', distancePc: 121.07 },
|
||||
{ id: 49441, name: 'Eta Leo', distancePc: 389.11 }
|
||||
];
|
||||
/** Their distances' errors, 2.1 % and 6.3 %, against Gaia's 6.9 % and 17 %. */
|
||||
const MAX_HIPPARCOS_PLACED_ERROR = 0.065;
|
||||
/**
|
||||
* Archive hosts whose colour is read off their temperature, which the card marks "from its
|
||||
* temperature" (23547de). Measured 54; the flag's write dropped from fetchExoplanets leaves none,
|
||||
* and every other check passes, since the round trip compares the flag with itself.
|
||||
*/
|
||||
const MIN_COLOURS_FROM_TEMPERATURE = 40;
|
||||
/**
|
||||
* Archive stars numbered after their host's name (62f81f2), so a refresh keeps each one's id and a
|
||||
* bookmark its star. Measured: 3 276 of the 3 277 take the id their name hashes to, one having
|
||||
* probed past a taken one. Numbered in arrival order, as before, none do, and nothing else fails.
|
||||
*/
|
||||
const MAX_ARCHIVE_IDS_OFF_THEIR_NAME = 5;
|
||||
/**
|
||||
* Every star the naked eye sees, kept at any distance (c64eea0): measured 8 898 of V 6.5 or
|
||||
* brighter, 1 663 of them past 250 pc. With no magnitude handed to placementDistancePc, 7 379 are
|
||||
* left and Rigel, Deneb and Alnilam are gone — and every other check passed, the HYG survivors
|
||||
* going down rather than up. HD 197770 and HD 45291 are two of the twelve only Gaia's bright
|
||||
* sources place, by position; without that lookup they are gone and the count drops by twelve,
|
||||
* which the floor alone would not see.
|
||||
*
|
||||
* HD 45951 is the one only SIMBAD's name for its Gaia source places, HYG's declination for it being
|
||||
* 31.7′ out. Placed along HYG's direction instead, every check here passed, with the star drawn
|
||||
* twice: there, and as its bare source 31.7′ away. So it is held to that source by designation.
|
||||
*
|
||||
* Twenty HYG rows of V 6.5 or brighter are still left out, for want of a distance: neither HYG nor
|
||||
* Hipparcos gives one, and Gaia DR3 has no source brighter than G 7.5 with a parallax five times
|
||||
* its error within a minute of arc, nor under the name SIMBAD gives. They are β Phe, φ Cas, χ Aur,
|
||||
* ο¹ Cen, Polis, 16 Sgr, ρ Cas, η Car, and HD 47240, 50820, 90772, 97534, 100198, 101205, 101947,
|
||||
* 129092, 151804, 185936, 202214 and 212466. A 21st, θ¹ Ori A (HYG 26155), is left out on purpose:
|
||||
* its V 4.98 and O7 are Hipparcos's for it and a companion together, and SIMBAD names it a source
|
||||
* of G 6.63 at 378 pc, which the map does not keep; placed, it was drawn brighter than θ¹ Ori C.
|
||||
*/
|
||||
const MIN_NAKED_EYE_STARS = 8_800;
|
||||
const NAKED_EYE_MAGNITUDE_V = 6.5;
|
||||
const REQUIRED_NAKED_EYE_STARS = ['Rigel', 'Deneb', 'Alnilam', 'HD 197770', 'HD 45291', 'HD 45951'];
|
||||
const IDENTIFIED_NAKED_EYE_STARS = [{ name: 'HD 45951', gaiaDesignation: 'Gaia DR3 3369454521490604416' }];
|
||||
const BLENDED_NAKED_EYE_ROWS = [{ id: 26155, name: 'θ¹ Ori A' }];
|
||||
|
||||
function validateStars(stars: StarRecord[]): void {
|
||||
assertCondition(stars.length > 0, 'No stars were produced.');
|
||||
|
||||
const ids = new Set<number>();
|
||||
for (const star of stars) {
|
||||
assertCondition(Number.isFinite(star.id), `Star has a non-numeric id: ${JSON.stringify(star)}`);
|
||||
// Under 2^30, which V8 keeps unboxed; past it every id is a heap number, and the app's boot
|
||||
// task grew by 230 ms when the nearby Gaia stars were numbered from 2 000 000 000.
|
||||
assertCondition(star.id >= 0 && star.id < 2 ** 30, `Star ${star.id} has an id outside 0 to 2^30.`);
|
||||
assertCondition(!ids.has(star.id), `Duplicate star id: ${star.id}`);
|
||||
ids.add(star.id);
|
||||
assertCondition(!!star.name, `Star ${star.id} has no name.`);
|
||||
@@ -51,7 +133,108 @@ function validateStars(stars: StarRecord[]): void {
|
||||
assertCondition(decoded[i].id === stars[i].id && decoded[i].name === stars[i].name, `Star catalogue round-trip altered record ${i}.`);
|
||||
assertCondition(decoded[i].spectralType === stars[i].spectralType, `Star catalogue round-trip lost the spectral type of star ${stars[i].id}.`);
|
||||
assertCondition(decoded[i].colorIndex === null === (stars[i].colorIndex === null), `Star catalogue round-trip changed whether star ${stars[i].id} has a colour index.`);
|
||||
assertCondition(
|
||||
decoded[i].magnitudeBand === stars[i].magnitudeBand &&
|
||||
decoded[i].colorSystem === stars[i].colorSystem &&
|
||||
decoded[i].distanceFromGaia === !!stars[i].distanceFromGaia &&
|
||||
decoded[i].colorFromTemperature === !!stars[i].colorFromTemperature,
|
||||
`Star catalogue round-trip changed the photometry of star ${stars[i].id}.`
|
||||
);
|
||||
// Stored as its square root in 65 535ths, up to 100 %; see `star-catalog.ts`.
|
||||
const error = stars[i].distanceError;
|
||||
assertCondition(
|
||||
error === undefined ? decoded[i].distanceError === undefined : Math.abs(Math.sqrt(decoded[i].distanceError!) - Math.sqrt(Math.min(1, error))) <= 0.5 / 65_535 + 1e-9,
|
||||
`Star catalogue round-trip changed the distance error of star ${stars[i].id}.`
|
||||
);
|
||||
}
|
||||
|
||||
// What each star says it was measured in. A band or an error dropped on the way still encodes,
|
||||
// decodes and draws; it shows only as a card reading "Not measured" for a star that was.
|
||||
const withoutBand = stars.filter((star) => star.magnitudeBand === undefined).length;
|
||||
assertCondition(withoutBand <= MAX_STARS_WITHOUT_BAND, `${withoutBand} stars have no magnitude band (at most ${MAX_STARS_WITHOUT_BAND} expected) — the band is being lost.`);
|
||||
const withoutError = stars.filter((star) => star.id !== SUN_STAR_ID && star.distanceError === undefined).length;
|
||||
assertCondition(
|
||||
withoutError <= MAX_STARS_WITHOUT_DISTANCE_ERROR,
|
||||
`${withoutError} stars have no distance error (at most ${MAX_STARS_WITHOUT_DISTANCE_ERROR} expected) — the parallax errors are being lost.`
|
||||
);
|
||||
console.log(` ${withoutBand} stars with a stand-in magnitude; ${withoutError} distances without a published error.`);
|
||||
|
||||
const gaiaErrors = stars
|
||||
.filter((star) => star.distanceFromGaia && star.distanceError !== undefined)
|
||||
.map((star) => star.distanceError!)
|
||||
.sort((a, b) => a - b);
|
||||
const medianGaiaError = gaiaErrors[Math.floor(gaiaErrors.length / 2)] ?? 0;
|
||||
assertCondition(
|
||||
medianGaiaError <= MAX_MEDIAN_GAIA_DISTANCE_ERROR,
|
||||
`The median error of Gaia's distances is ${(medianGaiaError * 100).toFixed(2)} % (at most ${MAX_MEDIAN_GAIA_DISTANCE_ERROR * 100} % expected) — the parallax errors are no longer relative.`
|
||||
);
|
||||
// The archive's errors are on the distance and never printed as a range; see formatDistance.
|
||||
const ranged = stars.filter((star) => star.source !== 'exoplanet-archive' && (star.distanceError ?? 0) >= 0.2).length;
|
||||
assertCondition(
|
||||
ranged <= stars.length * MAX_RANGED_DISTANCE_SHARE,
|
||||
`${ranged} parallax distances have an error of a fifth or more (at most ${MAX_RANGED_DISTANCE_SHARE * 100} % of stars expected).`
|
||||
);
|
||||
const hygAtGaiaDistance = stars.filter((star) => star.source === 'hyg' && star.distanceFromGaia).length;
|
||||
assertCondition(
|
||||
hygAtGaiaDistance >= MIN_HYG_STARS_AT_GAIA_DISTANCE,
|
||||
`Only ${hygAtGaiaDistance} HYG stars are flagged at Gaia's distance (at least ${MIN_HYG_STARS_AT_GAIA_DISTANCE} expected) — fetchStars no longer says whose parallax it placed them by.`
|
||||
);
|
||||
console.log(
|
||||
` Gaia distances a median ${(medianGaiaError * 100).toFixed(2)} % uncertain; ${ranged} parallax distances ranged; ${hygAtGaiaDistance} HYG stars at Gaia's distance.`
|
||||
);
|
||||
|
||||
const nakedEye = stars.filter((star) => star.magnitudeBand === 'V' && star.magnitude <= NAKED_EYE_MAGNITUDE_V && star.id !== SUN_STAR_ID).length;
|
||||
assertCondition(
|
||||
nakedEye >= MIN_NAKED_EYE_STARS,
|
||||
`Only ${nakedEye} stars of V ${NAKED_EYE_MAGNITUDE_V} or brighter (at least ${MIN_NAKED_EYE_STARS} expected) — naked-eye stars are no longer kept at any distance.`
|
||||
);
|
||||
for (const name of REQUIRED_NAKED_EYE_STARS) {
|
||||
assertCondition(stars.some((star) => star.name === name), `${name} is missing — naked-eye stars are no longer kept wherever a survey places them.`);
|
||||
}
|
||||
for (const expected of IDENTIFIED_NAKED_EYE_STARS) {
|
||||
assertCondition(
|
||||
stars.some((star) => star.name === expected.name && star.gaiaDesignation === expected.gaiaDesignation),
|
||||
`${expected.name} is not on ${expected.gaiaDesignation}, the source SIMBAD names it as — it is drawn where HYG has it, beside that source.`
|
||||
);
|
||||
}
|
||||
for (const blended of BLENDED_NAKED_EYE_ROWS) {
|
||||
assertCondition(!stars.some((star) => star.id === blended.id), `${blended.name} is drawn in the V and type of Hipparcos's blend of it with a companion.`);
|
||||
}
|
||||
console.log(` ${nakedEye} naked-eye stars.`);
|
||||
|
||||
const gaiaAtHipparcosDistance = stars.filter((star) => star.source === 'gaia' && star.magnitudeBand === 'V' && !star.distanceFromGaia).length;
|
||||
assertCondition(
|
||||
gaiaAtHipparcosDistance >= MIN_GAIA_STARS_AT_HIPPARCOS_DISTANCE,
|
||||
`Only ${gaiaAtHipparcosDistance} HYG stars folded into Gaia keep their Hipparcos distance (at least ${MIN_GAIA_STARS_AT_HIPPARCOS_DISTANCE} expected) — the more precise distance no longer wins.`
|
||||
);
|
||||
for (const expected of HIPPARCOS_PLACED_STARS) {
|
||||
const star = stars.find((candidate) => candidate.id === expected.id);
|
||||
const distancePc = star && Math.hypot(star.x, star.y, star.z);
|
||||
assertCondition(
|
||||
star !== undefined && Math.abs(distancePc! / expected.distancePc - 1) < 0.01 && !star.distanceFromGaia && (star.distanceError ?? 1) < MAX_HIPPARCOS_PLACED_ERROR,
|
||||
`${expected.name} is at ${distancePc?.toFixed(1)} pc, error ${star?.distanceError}, Gaia's: ${star?.distanceFromGaia} — expected its Hipparcos ${expected.distancePc} pc and error.`
|
||||
);
|
||||
}
|
||||
const coloursFromTemperature = stars.filter((star) => star.colorFromTemperature).length;
|
||||
assertCondition(
|
||||
coloursFromTemperature >= MIN_COLOURS_FROM_TEMPERATURE,
|
||||
`Only ${coloursFromTemperature} colours are marked as read off a temperature (at least ${MIN_COLOURS_FROM_TEMPERATURE} expected) — fetchExoplanets no longer says so.`
|
||||
);
|
||||
const archiveStars = stars.filter((star) => star.source === 'exoplanet-archive');
|
||||
const offTheirName = archiveStars.filter((star) => star.id !== archiveStarId(star.name, new Set())).length;
|
||||
assertCondition(
|
||||
offTheirName <= MAX_ARCHIVE_IDS_OFF_THEIR_NAME,
|
||||
`${offTheirName} of the ${archiveStars.length} archive stars have an id other than their name's (at most ${MAX_ARCHIVE_IDS_OFF_THEIR_NAME} expected) — a refresh would renumber them.`
|
||||
);
|
||||
console.log(
|
||||
` ${gaiaAtHipparcosDistance} Gaia stars at their Hipparcos distance; ${coloursFromTemperature} colours from a temperature; ${offTheirName} archive ids off their name.`
|
||||
);
|
||||
|
||||
|
||||
// Hipparcos's mark on a classification it does not print in full reached the card as "Spectral
|
||||
// type A0m...", for Sirius and 2 126 other stars, which reads as text the app cut short.
|
||||
const dotted = stars.filter((star) => star.spectralType.endsWith('...')).length;
|
||||
assertCondition(dotted === 0, `${dotted} spectral types end in "..." — fetchStars no longer trims Hipparcos's mark from them.`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,31 +247,52 @@ function validateStars(stars: StarRecord[]): void {
|
||||
*
|
||||
* A star kept twice leaves its two entries near each other on the sky, from *different* sources —
|
||||
* one catalogue does not list a star twice. Under an arcsecond that is never two stars at this
|
||||
* depth, so every such pair is a miss. Nineteen survive today, all of them a second HYG row
|
||||
* wanting a Gaia entry that already absorbed one (Gliese lists some doubles twice); the merge
|
||||
* that trusted a Hipparcos parallax over direction left 1 112.
|
||||
* depth, so every such pair is a miss. Twenty-two survive today; the nineteen first counted were
|
||||
* all a second HYG row wanting a Gaia entry that already absorbed one (Gliese lists some doubles
|
||||
* twice), and the merge that trusted a Hipparcos parallax over direction left 1 112.
|
||||
*
|
||||
* The other failure leaves no close pair at all, because proper motion had already carried the
|
||||
* two entries tens of arcseconds apart — the 2026-08-24 refresh, where HYG sat at epoch 2000.0
|
||||
* and Gaia at J2016.0. What it does leave is HYG rows that found no counterpart: 36 056 of them
|
||||
* against the 10 886 today, and no counterpart was possible for most of those. Two thirds of them,
|
||||
* 6 835, are the stars Gaia measures but the main query never downloads, because Gaia's parallax
|
||||
* puts them past `ETL_GAIA_DISTANCE_PC` while Hipparcos put them inside `ETL_STAR_DISTANCE_PC`;
|
||||
* they are every star in the published catalogue beyond 250 pc. The rest are what Gaia genuinely
|
||||
* lacks: bright stars it saturates on, red dwarfs past its magnitude cut. So the headroom left to
|
||||
* the ceiling tracks the gap between those two cutoffs as much as Gaia's completeness.
|
||||
* against the 11 464 today, and no counterpart was possible for most of those. 8 307 of them are
|
||||
* every star beyond 250 pc but the archive's planet hosts, which the main query never downloads: 1 660
|
||||
* naked-eye stars kept at any distance, and 6 647 fainter ones that Hipparcos put inside
|
||||
* `ETL_STAR_DISTANCE_PC` while Gaia's parallax puts them past `ETL_GAIA_DISTANCE_PC`. The other
|
||||
* 3 156 are what Gaia genuinely lacks: 1 202 brighter than V 8, which it saturates on or measures
|
||||
* poorly, 1 793 between 8 and 12, and 161 fainter, 111 of them Gliese stars within 50 pc that
|
||||
* neither of its queries holds. So the headroom left to the ceiling tracks the gap between those two cutoffs as
|
||||
* much as Gaia's completeness.
|
||||
*
|
||||
* This bounds a merge that went wrong, and — loosely — a Gaia download that came back short: a
|
||||
* truncated answer leaves the HYG rows whose counterpart it dropped without one, so survivors go
|
||||
* *up*, not down. Measured against the published catalogue: 10 886 today, 11 004 at nine tenths of
|
||||
* the rows, 12 711 at half, 16 258 at a third. So this ceiling only catches a truncation past about
|
||||
* two thirds, and `fetchGaiaStars` catches the shallower ones with its own row floor.
|
||||
* *up*, not down. Measured on the main query when it was the only one, with 10 886 survivors
|
||||
* against today's 11 464: 11 004 at nine tenths of its rows, 12 711 at half, 16 258 at a third. So
|
||||
* this ceiling only catches a deep truncation, and `fetchGaiaStars` catches the shallower ones
|
||||
* with a row floor on each query.
|
||||
*/
|
||||
const MAX_UNMERGED_TWINS = 100;
|
||||
/**
|
||||
* Gliese-only rows beside the Gaia entry SIMBAD names as the same star, which no geometry saw:
|
||||
* 49 beside a bare one before `foldByIdentity`, two of them false stars inside 10 pc (GJ 2097 at
|
||||
* 6.41 pc and GJ 4285 at 6.80, which Gaia has at 24.47 and 28.25), and 10 beside one a Hipparcos row
|
||||
* already described, Gl 251 at 5.76 pc beside HD 265866. The twin count above does not see them,
|
||||
* being up to minutes of arc apart.
|
||||
*
|
||||
* That count takes SIMBAD's map from the function the fold takes it from, so a slip in the map
|
||||
* turns both off together: with it empty, GJ 2097 and GJ 4285 were back inside 10 pc and the count
|
||||
* read none. These are checked by name and distance instead.
|
||||
*/
|
||||
const MAX_GLIESE_ROWS_BESIDE_THEIR_GAIA_SOURCE = 0;
|
||||
const FOLDED_GLIESE_STARS = [
|
||||
{ name: 'GJ 2097', beyondPc: 20 },
|
||||
{ name: 'GJ 4285', beyondPc: 20 }
|
||||
];
|
||||
/** Gliese-only rows of stars HYG also lists by their Hipparcos row, HD 265866 and HD 304043. */
|
||||
const ABSORBED_GLIESE_ROWS = ['Gl 251', 'Gl 422'];
|
||||
const MAX_HYG_SURVIVORS = 15_000;
|
||||
const TWIN_TOLERANCE_RAD = (1 / 3600) * (Math.PI / 180);
|
||||
|
||||
function validateMerge(stars: StarRecord[]): void {
|
||||
function validateMerge(stars: StarRecord[], gaiaDesignationById: ReadonlyMap<number, string>): void {
|
||||
// Checked first and on its own: an unreachable Gaia is skipped rather than thrown, and would
|
||||
// otherwise surface below as "68 000 HYG stars found no counterpart" — true, and no help.
|
||||
assertCondition(
|
||||
@@ -132,7 +336,27 @@ function validateMerge(stars: StarRecord[]): void {
|
||||
twins <= MAX_UNMERGED_TWINS,
|
||||
`${twins} stars from different catalogues sit within an arcsecond of each other (at most ${MAX_UNMERGED_TWINS} expected), starting with ${example} — the merge is keeping the same star twice.`
|
||||
);
|
||||
console.log(` ${survivors} HYG stars have no Gaia counterpart; ${twins} unmerged cross-catalogue pairs within an arcsecond.`);
|
||||
const byDesignation = new Map(stars.filter((star) => star.gaiaDesignation !== undefined).map((star) => [star.gaiaDesignation!, star]));
|
||||
const beside = stars.filter((star) => {
|
||||
const target = star.source === 'hyg' && star.distanceError === undefined ? byDesignation.get(gaiaDesignationById.get(star.id) ?? '') : undefined;
|
||||
return target !== undefined && foldsInto(target, star);
|
||||
});
|
||||
assertCondition(
|
||||
beside.length <= MAX_GLIESE_ROWS_BESIDE_THEIR_GAIA_SOURCE,
|
||||
`${beside.length} Gliese stars are drawn beside the Gaia source SIMBAD names them as, starting with ${beside[0]?.name} — the identity fold is not being made.`
|
||||
);
|
||||
for (const expected of FOLDED_GLIESE_STARS) {
|
||||
const star = stars.find((candidate) => candidate.name === expected.name);
|
||||
const distancePc = star && Math.hypot(star.x, star.y, star.z);
|
||||
assertCondition(
|
||||
distancePc !== undefined && distancePc > expected.beyondPc,
|
||||
`${expected.name} is at ${distancePc?.toFixed(2)} pc, not beyond ${expected.beyondPc} where Gaia measures it — Gliese stars are no longer folded into their Gaia source.`
|
||||
);
|
||||
}
|
||||
for (const name of ABSORBED_GLIESE_ROWS) {
|
||||
assertCondition(!stars.some((star) => star.name === name), `${name} is drawn beside the Hipparcos star it is — Gliese stars are no longer folded into their Gaia source.`);
|
||||
}
|
||||
console.log(` ${survivors} HYG stars have no Gaia counterpart; ${twins} unmerged cross-catalogue pairs within an arcsecond; ${beside.length} Gliese stars beside their own Gaia source.`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -484,14 +708,59 @@ function validateBodies(bodies: BodyRecord[], horizonsOrbits: Map<string, Orbita
|
||||
console.log(` IAU rotation against each record's day and tilt (see MAX_DAY_OFFSET for where each comes from; day as a fraction of it, tilt and a locked moon's face in degrees): ${spins.join(', ')}.`);
|
||||
}
|
||||
|
||||
function validateExoplanets(exoplanets: ExoplanetRecord[], starIds: Set<number>): void {
|
||||
/**
|
||||
* The share of planets that must have a star on the map: 6 327 of 6 354 did when the ETL began
|
||||
* adding the hosts the catalogue lacks from the archive's own figures, up from 2 071, and 6 328
|
||||
* once a blank sy_dist gave way to the parallax (mu2 Sco b). The other 26 have neither a distance
|
||||
* nor a parallax in either archive table, so nothing can place them; the floor leaves room
|
||||
* for a few more of those, not for the matching or the additions to stop working.
|
||||
*/
|
||||
const MIN_HOSTED_SHARE = 0.995;
|
||||
/**
|
||||
* Planets whose host only one path places, which the share above has room to lose: mu2 Sco b's
|
||||
* archive rows give a parallax and no distance, and without the parallax it had no star — 6 327
|
||||
* hosted instead of 6 328, and every check passed.
|
||||
*/
|
||||
const REQUIRED_HOSTED_PLANETS = ['mu2 Sco b'];
|
||||
/**
|
||||
* Two hosts' luminosities as the archive gives them, one either side of the Sun's: st_lum −2.821
|
||||
* for Proxima (Ribas et al. 2017 measure 1.51×10⁻³ L☉) and +1.602 for HD 97048. That every
|
||||
* luminosity is positive catches st_lum stored unconverted and nothing else: read as 10^−x or e^x,
|
||||
* Proxima came out 662 or 0.0595 L☉ and every check passed.
|
||||
*/
|
||||
const LUMINOSITY_ANCHORS = [
|
||||
{ planet: 'Proxima Cen b', luminositySolar: 1.51e-3 },
|
||||
{ planet: 'HD 97048 b', luminositySolar: 40 }
|
||||
];
|
||||
const LUMINOSITY_ANCHOR_TOLERANCE = 0.1;
|
||||
|
||||
/**
|
||||
* The share of planets whose host's radius and temperature the archive gives, which is what
|
||||
* starSurfaceOf draws a host with before deriving one. Measured 6 030 and 6 054 of 6 354 (0.95).
|
||||
* Both come from the composite table only; a column lost on the way leaves every host derived —
|
||||
* Proxima 0.105 R☉ instead of 0.141 — and nothing else fails.
|
||||
*/
|
||||
const MIN_HOST_SURFACE_SHARE = 0.9;
|
||||
/**
|
||||
* How far, in milliarcseconds, a star placed from the archive may sit from its planets' published
|
||||
* position carried from the archive's epoch to the catalogue's. Measured 0.0001 at most, rounding;
|
||||
* carried from J2016 instead, TOI-2406 moves 203 mas and 2 287 archive stars more than 1.
|
||||
*/
|
||||
const MAX_ARCHIVE_EPOCH_OFFSET_MAS = 1;
|
||||
|
||||
function validateExoplanets(exoplanets: ExoplanetRecord[], stars: StarRecord[]): void {
|
||||
assertCondition(exoplanets.length > 0, 'No exoplanets were produced.');
|
||||
const starsById = new Map(stars.map((star) => [star.id, star]));
|
||||
|
||||
let crossReferenced = 0;
|
||||
for (const exoplanet of exoplanets) {
|
||||
assertCondition(!!exoplanet.name, `Exoplanet ${exoplanet.id} has no name.`);
|
||||
if (exoplanet.hostStarId !== null) {
|
||||
assertCondition(starIds.has(exoplanet.hostStarId), `Exoplanet ${exoplanet.id} references unknown star id ${exoplanet.hostStarId}.`);
|
||||
const host = starsById.get(exoplanet.hostStarId);
|
||||
assertCondition(host !== undefined, `Exoplanet ${exoplanet.id} references unknown star id ${exoplanet.hostStarId}.`);
|
||||
// A host known only as "Gaia DR3 2635476908753563008" cannot be found by searching for
|
||||
// TRAPPIST-1; fetchExoplanets names it after its host, and 574 were renamed.
|
||||
assertCondition(!isDesignation(host!), `Exoplanet ${exoplanet.id}'s host is only a designation, ${host!.name}, not named after ${exoplanet.hostStarName}.`);
|
||||
// The Sun has no exoplanets, so any match to it is a matching failure — historically a
|
||||
// blank distance column parsing as 0, which puts the host at the origin and matches Sol
|
||||
// exactly. Free, permanent tripwire for that whole class of bug.
|
||||
@@ -512,7 +781,58 @@ function validateExoplanets(exoplanets: ExoplanetRecord[], starIds: Set<number>)
|
||||
);
|
||||
}
|
||||
|
||||
console.log(` ${crossReferenced}/${exoplanets.length} exoplanets cross-referenced to a HYG host star.`);
|
||||
console.log(` ${crossReferenced}/${exoplanets.length} exoplanets have a host star on the map.`);
|
||||
assertCondition(
|
||||
crossReferenced >= exoplanets.length * MIN_HOSTED_SHARE,
|
||||
`Only ${crossReferenced} of ${exoplanets.length} exoplanets have a host star (at least ${MIN_HOSTED_SHARE * 100} % expected) — hosts are no longer being matched or added.`
|
||||
);
|
||||
for (const name of REQUIRED_HOSTED_PLANETS) {
|
||||
const planet = exoplanets.find((candidate) => candidate.name === name);
|
||||
assertCondition(planet?.hostStarId != null, `${name} has no host star — a host the archive places by its parallax alone is no longer placed.`);
|
||||
}
|
||||
|
||||
const withRadius = exoplanets.filter((exoplanet) => exoplanet.hostStarRadiusSolar !== undefined).length;
|
||||
const withTemperature = exoplanets.filter((exoplanet) => exoplanet.hostStarTemperatureK !== undefined).length;
|
||||
assertCondition(
|
||||
Math.min(withRadius, withTemperature) >= exoplanets.length * MIN_HOST_SURFACE_SHARE,
|
||||
`Only ${withRadius} of ${exoplanets.length} exoplanets carry their host's radius and ${withTemperature} its temperature (at least ${MIN_HOST_SURFACE_SHARE * 100} % expected) — st_rad or st_teff is being lost.`
|
||||
);
|
||||
// Since d94451e st_lum warms the planets of 4 441 hosts and is what their cards print; lost, each
|
||||
// falls back to a derived luminosity, 757 of them more than 1.5 times off it. Measured 6 036.
|
||||
const luminosities = exoplanets.map((exoplanet) => exoplanet.hostStarLuminositySolar).filter((luminosity) => luminosity !== undefined);
|
||||
assertCondition(
|
||||
luminosities.length >= exoplanets.length * MIN_HOST_SURFACE_SHARE,
|
||||
`Only ${luminosities.length} of ${exoplanets.length} exoplanets carry their host's luminosity (at least ${MIN_HOST_SURFACE_SHARE * 100} % expected) — st_lum is being lost.`
|
||||
);
|
||||
// Published as a logarithm, most of them negative: stored unconverted, they would not be.
|
||||
assertCondition(
|
||||
luminosities.every((luminosity) => Number.isFinite(luminosity) && luminosity! > 0),
|
||||
'A host luminosity is not a positive number — st_lum is no longer converted from its logarithm.'
|
||||
);
|
||||
for (const anchor of LUMINOSITY_ANCHORS) {
|
||||
const luminosity = exoplanets.find((exoplanet) => exoplanet.name === anchor.planet)?.hostStarLuminositySolar;
|
||||
assertCondition(
|
||||
luminosity !== undefined && Math.abs(luminosity / anchor.luminositySolar - 1) <= LUMINOSITY_ANCHOR_TOLERANCE,
|
||||
`${anchor.planet}'s host is ${luminosity} L☉, not the ${anchor.luminositySolar} its st_lum gives — st_lum is not being read as the base-10 logarithm it is.`
|
||||
);
|
||||
}
|
||||
console.log(` ${withRadius}/${exoplanets.length} carry their host's radius, ${withTemperature} its temperature, ${luminosities.length} its luminosity.`);
|
||||
|
||||
let worstOffsetMas = 0;
|
||||
for (const star of stars.filter((candidate) => candidate.source === 'exoplanet-archive')) {
|
||||
const planet = exoplanets.find((candidate) => candidate.hostStarId === star.id)!;
|
||||
const at = propagateProperMotion(planet.hostRaDeg!, planet.hostDecDeg!, planet.hostPmRaMasPerYear ?? 0, planet.hostPmDecMasPerYear ?? 0, CATALOGUE_EPOCH - ARCHIVE_EPOCH);
|
||||
const expected = raDegDecDistanceToXyz(at.raDeg, at.decDeg, 1);
|
||||
const length = Math.hypot(star.x, star.y, star.z);
|
||||
// The chord between the two directions, which unlike an arccosine resolves a milliarcsecond.
|
||||
const chord = Math.hypot(star.x / length - expected.x, star.y / length - expected.y, star.z / length - expected.z);
|
||||
worstOffsetMas = Math.max(worstOffsetMas, (chord * 180 * 3_600_000) / Math.PI);
|
||||
}
|
||||
assertCondition(
|
||||
worstOffsetMas <= MAX_ARCHIVE_EPOCH_OFFSET_MAS,
|
||||
`A star placed from the archive sits ${worstOffsetMas.toFixed(1)} mas from its published position carried to J2000 (at most ${MAX_ARCHIVE_EPOCH_OFFSET_MAS} expected) — it is carried from another epoch.`
|
||||
);
|
||||
console.log(` Stars placed from the archive at most ${worstOffsetMas.toExponential(1)} mas from their published position carried to J2000.`);
|
||||
|
||||
// How many can be propagated at their real rate rather than as if the host were the Sun.
|
||||
const withPeriod = exoplanets.filter((exoplanet) => exoplanet.periodDays !== undefined).length;
|
||||
@@ -538,6 +858,19 @@ const MIN_IMAGED_EXOPLANETS = 95;
|
||||
|
||||
const UNIT_VECTOR_TOLERANCE = 1e-6;
|
||||
|
||||
/**
|
||||
* Objects the backdrop cannot ship without, two from each of OpenNGC's files: the Andromeda
|
||||
* Galaxy and the Small Magellanic Cloud from NGC.csv, the Large Magellanic Cloud and the
|
||||
* Pleiades from addendum.csv. The addendum went unread for as long as the ETL has existed,
|
||||
* because 463 objects without the brightest deep-sky object in the sky validated cleanly.
|
||||
*/
|
||||
const REQUIRED_DEEP_SKY_IDS = ['NGC0224', 'NGC0292', 'ESO056-115', 'Mel022'];
|
||||
/**
|
||||
* 107 of the 110 Messier objects. OpenNGC types the other three as what they are: M40 a double
|
||||
* star, M73 an asterism and M102 a duplicate of M101, none of them a deep-sky object to draw.
|
||||
*/
|
||||
const MIN_MESSIER_OBJECTS = 107;
|
||||
|
||||
function validateDeepSky(objects: DeepSkyRecord[]): void {
|
||||
assertCondition(objects.length > 0, 'No deep-sky objects were produced.');
|
||||
|
||||
@@ -568,6 +901,12 @@ function validateDeepSky(objects: DeepSkyRecord[]): void {
|
||||
assertCondition(kinds.has(kind), `No deep-sky objects of kind "${kind}" were produced.`);
|
||||
}
|
||||
|
||||
for (const id of REQUIRED_DEEP_SKY_IDS) {
|
||||
assertCondition(ids.has(id), `Deep-sky object ${id} is missing — one of OpenNGC's two files was not read.`);
|
||||
}
|
||||
const messier = new Set(objects.map((object) => object.messier).filter((designation) => designation !== null)).size;
|
||||
assertCondition(messier >= MIN_MESSIER_OBJECTS, `Only ${messier} Messier objects were produced (at least ${MIN_MESSIER_OBJECTS} expected).`);
|
||||
|
||||
const withDistance = objects.filter((object) => object.distancePc !== null).length;
|
||||
console.log(` ${withDistance}/${objects.length} deep-sky objects have a derived distance.`);
|
||||
}
|
||||
@@ -583,20 +922,21 @@ async function build(): Promise<void> {
|
||||
console.log(describeSources());
|
||||
console.log();
|
||||
|
||||
const stars = await fetchStars();
|
||||
const catalogueStars = await fetchStars();
|
||||
console.log();
|
||||
const { bodies, horizonsOrbits, horizonsTracks } = await fetchSolarSystem();
|
||||
console.log();
|
||||
const exoplanets = await fetchExoplanets(stars);
|
||||
// Adds the hosts the catalogue lacks, so it is this list, not the one above, that is published.
|
||||
const { exoplanets, stars } = await fetchExoplanets(catalogueStars);
|
||||
console.log();
|
||||
const deepSky = await fetchDeepSky();
|
||||
console.log();
|
||||
|
||||
console.log('Validating output...');
|
||||
validateStars(stars);
|
||||
validateMerge(stars);
|
||||
validateMerge(stars, await glieseGaiaDesignations());
|
||||
validateBodies(bodies, horizonsOrbits, horizonsTracks);
|
||||
validateExoplanets(exoplanets, new Set(stars.map((star) => star.id)));
|
||||
validateExoplanets(exoplanets, stars);
|
||||
validateDeepSky(deepSky);
|
||||
|
||||
console.log('\nETL completed successfully:');
|
||||
|
||||
@@ -8,6 +8,12 @@ import { fetchTextCached } from './lib/http';
|
||||
import { dataPath, ensureDataDir } from './lib/paths';
|
||||
|
||||
const OPENNGC_CSV_URL = 'https://raw.githubusercontent.com/mattiaverga/OpenNGC/master/database_files/NGC.csv';
|
||||
/**
|
||||
* OpenNGC's second file, with the same columns, for the objects no NGC or IC number covers:
|
||||
* the Pleiades, the Hyades, the Large Magellanic Cloud, the Horsehead, the Coalsack. Without
|
||||
* it the brightest deep-sky object in the sky is missing while the Small Cloud is drawn.
|
||||
*/
|
||||
const OPENNGC_ADDENDUM_CSV_URL = 'https://raw.githubusercontent.com/mattiaverga/OpenNGC/master/database_files/addendum.csv';
|
||||
/** OpenNGC publishes semicolon-separated files, not comma-separated. */
|
||||
const OPENNGC_DELIMITER = ';';
|
||||
|
||||
@@ -64,8 +70,10 @@ function resolveName(commonName: string | null, messier: string | null, designat
|
||||
*/
|
||||
export async function fetchDeepSky(): Promise<DeepSkyRecord[]> {
|
||||
console.log('Fetching OpenNGC deep-sky catalog...');
|
||||
const csv = await fetchTextCached(OPENNGC_CSV_URL, 'openngc.csv');
|
||||
const rows = parseCsvObjects(csv, OPENNGC_DELIMITER);
|
||||
const rows = [
|
||||
...parseCsvObjects(await fetchTextCached(OPENNGC_CSV_URL, 'openngc.csv'), OPENNGC_DELIMITER),
|
||||
...parseCsvObjects(await fetchTextCached(OPENNGC_ADDENDUM_CSV_URL, 'openngc-addendum.csv'), OPENNGC_DELIMITER)
|
||||
];
|
||||
|
||||
const records: DeepSkyRecord[] = [];
|
||||
let skippedUnclassified = 0;
|
||||
|
||||
+140
-18
@@ -1,10 +1,13 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
|
||||
import { buildStarNameIndex, resolveHostStarId } from '../../src/app/shared/astro/host-star-matching';
|
||||
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 } from './fetchStars';
|
||||
import { fetchStars, writeStarAssets } from './fetchStars';
|
||||
import { parseCsvObjects, parseOptionalNumber } from './lib/csv';
|
||||
import { fetchTextCached } from './lib/http';
|
||||
import { dataPath, ensureDataDir } from './lib/paths';
|
||||
@@ -52,35 +55,140 @@ const IMAGED_URL = `${TAP_BASE_URL}?query=select+pl_name+from+ps+where+default_f
|
||||
const IMAGED_CACHE_FILE = `exoplanet-archive-imaged-${createHash('sha1').update(IMAGED_URL).digest('hex').slice(0, 8)}.csv`;
|
||||
|
||||
/**
|
||||
* Downloads confirmed exoplanets from the NASA Exoplanet Archive (`Planetary Systems` TAP
|
||||
* table), cross-references each host star to the HYG index, and writes `exoplanets.json`.
|
||||
* 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.
|
||||
*/
|
||||
export async function fetchExoplanets(stars?: StarRecord[]): Promise<ExoplanetRecord[]> {
|
||||
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]));
|
||||
const imaged = new Set(parseCsvObjects(await fetchTextCached(IMAGED_URL, IMAGED_CACHE_FILE)).map((row) => row['pl_name']));
|
||||
|
||||
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 raDeg = parseOptionalNumber(row['ra']) ?? Number.NaN;
|
||||
const decDeg = parseOptionalNumber(row['dec']) ?? Number.NaN;
|
||||
const distancePc = parseOptionalNumber(row['sy_dist']) ?? Number.NaN;
|
||||
const pmRaMasPerYear = parseOptionalNumber(row['sy_pmra']);
|
||||
const pmDecMasPerYear = parseOptionalNumber(row['sy_pmdec']);
|
||||
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');
|
||||
|
||||
const hostStarId = resolveHostStarId(
|
||||
{ hostname: row['hostname'], raDeg, decDeg, distancePc, pmRaMasPerYear, pmDecMasPerYear },
|
||||
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 {
|
||||
@@ -96,12 +204,16 @@ export async function fetchExoplanets(stars?: StarRecord[]): Promise<ExoplanetRe
|
||||
// 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: parseOptionalNumber(row['st_mass']),
|
||||
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: parseOptionalNumber(row['ra']),
|
||||
hostDecDeg: parseOptionalNumber(row['dec']),
|
||||
hostDistancePc: parseOptionalNumber(row['sy_dist']),
|
||||
hostRaDeg: host('ra'),
|
||||
hostDecDeg: host('dec'),
|
||||
hostDistancePc: host('sy_dist'),
|
||||
hostPmRaMasPerYear: pmRaMasPerYear,
|
||||
hostPmDecMasPerYear: pmDecMasPerYear,
|
||||
orbit: {
|
||||
@@ -113,10 +225,20 @@ export async function fetchExoplanets(stars?: StarRecord[]): Promise<ExoplanetRe
|
||||
};
|
||||
});
|
||||
|
||||
// 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} cross-referenced to a HYG host star).`);
|
||||
return 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) {
|
||||
|
||||
+138
-25
@@ -1,17 +1,17 @@
|
||||
import { writeFileSync } from 'node:fs';
|
||||
|
||||
import { mergeStarCatalogues, placementDistancePc } from '../../src/app/shared/astro/star-merge';
|
||||
import { foldByIdentity, hipparcosDistancePc, HYG_UNKNOWN_DISTANCE_PC, mergeStarCatalogues, NAKED_EYE_MAGNITUDE, placementDistancePc } from '../../src/app/shared/astro/star-merge';
|
||||
import { encodeStarCatalog } from '../../src/app/shared/models/star-catalog';
|
||||
import { StarRecord, SUN_STAR_ID } from '../../src/app/shared/models/star.model';
|
||||
import { fetchGaiaDistancesByHip, GaiaAnswerError } from './sources/gaia';
|
||||
import { BrightGaiaSource, fetchBrightGaiaSources, fetchGaiaDistancesByHip, fetchHipparcosParallaxErrors, GaiaAnswerError } from './sources/gaia';
|
||||
import { positionalSources } from './sources/registry';
|
||||
import { fetchGaiaDesignationsByGj, fetchGaiaDesignationsByHd } from './sources/simbad';
|
||||
import { PARALLAX_PRECISION_MAS } from './sources/star-sources';
|
||||
import { parseCsvObjects, parseOptionalNumber } from './lib/csv';
|
||||
import { fetchTextCached } from './lib/http';
|
||||
import { dataPath, ensureDataDir } from './lib/paths';
|
||||
|
||||
const HYG_CSV_URL = 'https://raw.githubusercontent.com/astronexus/HYG-Database/main/hyg/CURRENT/hygdata_v41.csv';
|
||||
const HYG_UNKNOWN_DISTANCE_PC = 100000; // HYG's placeholder for unmeasured/unreliable parallax
|
||||
|
||||
/**
|
||||
* Stand-in magnitude for a star with no photometry. Faint rather than 0, because 0 would mean
|
||||
@@ -21,7 +21,8 @@ const UNKNOWN_MAGNITUDE = 15;
|
||||
|
||||
/**
|
||||
* Stars either survey places within this distance (parsecs) of the Sun are kept for the galaxy
|
||||
* view; `placementDistancePc` decides which distance a kept star is drawn at.
|
||||
* view, and every naked-eye star wherever it is; `placementDistancePc` decides which distance a
|
||||
* kept star is drawn at.
|
||||
*
|
||||
* Set at the range Hipparcos's own measurements reach rather than at a round number: its
|
||||
* parallaxes are good to roughly a milliarcsecond, so at 250 pc (4 mas) a distance is uncertain
|
||||
@@ -59,38 +60,73 @@ function resolveName(row: Record<string, string>): string {
|
||||
return `HYG ${row['id']}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* HYG's spectral type, without the `...` that 2 127 of the map's stars end in, all of them
|
||||
* Hipparcos stars: the Hipparcos catalogue's mark for a classification it does not print in full
|
||||
* (Sirius is "A0m..."). On the map it read as text the app had cut short.
|
||||
*/
|
||||
function spectralTypeOf(row: Record<string, string>): string {
|
||||
return (row['spect'] ?? '').replace(/\.\.\.$/, '') || 'Unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads the HYG (Hipparcos/Yale/Gliese) stellar database, places each star along its
|
||||
* equatorial direction (epoch J2000.0) at the better of its Hipparcos and Gaia distances, keeps
|
||||
* the ones either survey puts within range, unions the other positional sources, and writes
|
||||
* `stars.bin` (packed positions) + `stars-index.json` (everything else).
|
||||
* equatorial direction (epoch J2000.0) at whichever of its Hipparcos and Gaia distances has the
|
||||
* smaller error, keeps the ones either survey puts within range, and unions the other positional
|
||||
* sources. Writes nothing: fetchExoplanets adds the hosts the catalogue lacks and renames the
|
||||
* ones known only by a designation, then writes the star assets once. Written here as well, they
|
||||
* stood on disk without those 3 277 stars whenever a run stopped between the two — and that
|
||||
* catalogue left 4 237 planets pointing at stars it did not have.
|
||||
*/
|
||||
export async function fetchStars(): Promise<StarRecord[]> {
|
||||
console.log(`Fetching HYG star catalog (distance cutoff: ${DISTANCE_CUTOFF_PC} pc)...`);
|
||||
const csv = await fetchTextCached(HYG_CSV_URL, 'hygdata_v41.csv');
|
||||
const rows = parseCsvObjects(csv);
|
||||
// Not skipped when unreachable, unlike the positional sources below; see its own comment.
|
||||
const gaiaPcByHip = await fetchGaiaDistancesByHip();
|
||||
const gaiaByHip = await fetchGaiaDistancesByHip();
|
||||
const hipparcosErrors = await fetchHipparcosParallaxErrors();
|
||||
const brightGaia = await fetchBrightGaiaSources();
|
||||
const brightByDesignation = new Map(brightGaia.map((source) => [source.designation, source]));
|
||||
const nakedEyeDesignations = await fetchGaiaDesignationsByHd(
|
||||
rows.filter((row) => row['hd'] && Number(row['dist']) >= HYG_UNKNOWN_DISTANCE_PC && (parseOptionalNumber(row['mag']) ?? Infinity) <= NAKED_EYE_MAGNITUDE).map((row) => row['hd'])
|
||||
);
|
||||
|
||||
const stars: StarRecord[] = [];
|
||||
let atGaiaDistance = 0;
|
||||
let pastCutoff = 0;
|
||||
let identified = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
const id = Number(row['id']);
|
||||
|
||||
if (id === SUN_STAR_ID) {
|
||||
stars.push({ id, name: 'Sol', x: 0, y: 0, z: 0, magnitude: parseOptionalNumber(row['mag']) ?? UNKNOWN_MAGNITUDE, spectralType: row['spect'] || 'G2V', colorIndex: parseOptionalNumber(row['ci']) ?? null });
|
||||
stars.push({ id, name: 'Sol', x: 0, y: 0, z: 0, magnitude: parseOptionalNumber(row['mag']) ?? UNKNOWN_MAGNITUDE, magnitudeBand: 'V', spectralType: row['spect'] || 'G2V', colorIndex: parseOptionalNumber(row['ci']) ?? null, colorSystem: 'B-V' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const hygPc = Number(row['dist']);
|
||||
const hipparcosPc = Number.isFinite(hygPc) && hygPc > 0 && hygPc < HYG_UNKNOWN_DISTANCE_PC ? hygPc : undefined;
|
||||
const gaiaPc = row['hip'] ? gaiaPcByHip.get(Number(row['hip'])) : undefined;
|
||||
const distancePc = placementDistancePc(hipparcosPc, gaiaPc, DISTANCE_CUTOFF_PC);
|
||||
const hipparcos = row['hip'] ? hipparcosErrors.get(Number(row['hip'])) : undefined;
|
||||
const hipparcosPc = hipparcosDistancePc(hygPc, hipparcos);
|
||||
const magnitudeV = parseOptionalNumber(row['mag']);
|
||||
const magnitude = magnitudeV ?? UNKNOWN_MAGNITUDE;
|
||||
const unplaced = hipparcosPc === undefined && magnitude <= NAKED_EYE_MAGNITUDE;
|
||||
const crossMatched = row['hip'] ? gaiaByHip.get(Number(row['hip'])) : undefined;
|
||||
const positional = crossMatched === undefined && unplaced ? brightCounterpart(row, magnitude, brightGaia) : undefined;
|
||||
// Where HYG's position leads to no bright source, the one SIMBAD names it as: HD 45951, whose
|
||||
// declination HYG has 31.7′ out. Within a magnitude of HYG's V, as by position: θ¹ Ori A
|
||||
// (HD 37020) has V 4.98 and O7 in HYG, Hipparcos's entry for it and a companion together, and
|
||||
// its own source G 6.63 (SIMBAD: V 6.73, B0V). Placed, it was drawn brighter than θ¹ Ori C and
|
||||
// counted as naked-eye; at 378 pc and its own magnitude the map does not keep it.
|
||||
const named = crossMatched === undefined && positional === undefined && unplaced ? brightByDesignation.get(nakedEyeDesignations.get(`HD ${row['hd']}`) ?? '') : undefined;
|
||||
const byIdentity = named && Math.abs(named.magnitudeG - magnitude) <= BRIGHT_COUNTERPART_MAGNITUDES ? named : undefined;
|
||||
const gaia = crossMatched ?? positional ?? byIdentity;
|
||||
const gaiaPc = gaia?.distancePc;
|
||||
const hipparcosError = hipparcos?.relativeError;
|
||||
const distancePc = placementDistancePc(hipparcosPc, gaiaPc, magnitude, DISTANCE_CUTOFF_PC, hipparcosError, gaia?.relativeError);
|
||||
if (distancePc === null) {
|
||||
continue;
|
||||
}
|
||||
const fromGaia = gaia !== undefined && distancePc === gaiaPc;
|
||||
|
||||
// HYG's own Cartesian columns rather than its `ra`/`dec`, which are in the same frame as
|
||||
// `raDecDistanceToXyz` and would be redundant if the two agreed. They do not, for the stars
|
||||
@@ -101,42 +137,118 @@ export async function fetchStars(): Promise<StarRecord[]> {
|
||||
// HIP 57146, has x/y/z 161″ from its own ra/dec and stays double).
|
||||
//
|
||||
// Only their direction is used. They sit at HYG's own distance, or at its 100 000 pc
|
||||
// placeholder where it has none, and are carried along that direction to the one chosen above.
|
||||
const x = Number(row['x']);
|
||||
const y = Number(row['y']);
|
||||
const z = Number(row['z']);
|
||||
// placeholder where it has none, and are carried along that direction to the one chosen above —
|
||||
// Gaia's, for a star found by its identity, where HYG's may be the thing that is wrong.
|
||||
const [x, y, z] = byIdentity ? [byIdentity.direction.x, byIdentity.direction.y, byIdentity.direction.z] : [Number(row['x']), Number(row['y']), Number(row['z'])];
|
||||
const length = Math.hypot(x, y, z);
|
||||
if (![x, y, z].every(Number.isFinite) || length === 0) {
|
||||
continue;
|
||||
}
|
||||
const scale = distancePc / length;
|
||||
if (gaiaPc !== undefined) {
|
||||
if (fromGaia) {
|
||||
atGaiaDistance++;
|
||||
}
|
||||
if (byIdentity) {
|
||||
identified++;
|
||||
}
|
||||
if (distancePc > DISTANCE_CUTOFF_PC) {
|
||||
pastCutoff++;
|
||||
}
|
||||
|
||||
// Gaia's error with Gaia's distance, Hipparcos's with its own; a Gliese row, with neither, has
|
||||
// no published error, and its distance is as often photometric as measured.
|
||||
const distanceError = fromGaia ? gaia.relativeError : hipparcosError;
|
||||
const colorIndex = parseOptionalNumber(row['ci']);
|
||||
|
||||
stars.push({
|
||||
id,
|
||||
name: resolveName(row),
|
||||
x: x * scale,
|
||||
y: y * scale,
|
||||
z: z * scale,
|
||||
magnitude: parseOptionalNumber(row['mag']) ?? UNKNOWN_MAGNITUDE,
|
||||
spectralType: row['spect'] || 'Unknown',
|
||||
colorIndex: parseOptionalNumber(row['ci']) ?? null
|
||||
magnitude,
|
||||
...(magnitudeV === undefined ? {} : { magnitudeBand: 'V' as const }),
|
||||
spectralType: spectralTypeOf(row),
|
||||
colorIndex: colorIndex ?? null,
|
||||
...(colorIndex === undefined ? {} : { colorSystem: 'B-V' as const }),
|
||||
...(distanceError === undefined ? {} : { distanceError }),
|
||||
distanceFromGaia: fromGaia,
|
||||
// Only for the Gliese-only rows, whose positions are what the merge needs the motion to see
|
||||
// past. A Hipparcos position is good to under an arcsecond; given its motion too, 15 stars
|
||||
// took their co-moving companion's Gaia entry, and the companion was kept twice.
|
||||
...(row['hip'] ? {} : { pmRaMasYr: parseOptionalNumber(row['pmra']), pmDecMasYr: parseOptionalNumber(row['pmdec']) })
|
||||
});
|
||||
}
|
||||
|
||||
console.log(` kept ${stars.length} stars (of ${rows.length} in the catalog): ${atGaiaDistance} at Gaia's distance, ${pastCutoff} of them past ${DISTANCE_CUTOFF_PC} pc.`);
|
||||
console.log(
|
||||
` kept ${stars.length} stars (of ${rows.length} in the catalog): ${atGaiaDistance} at Gaia's distance, ${pastCutoff} of them past ${DISTANCE_CUTOFF_PC} pc; ${identified} naked-eye stars placed by the Gaia source SIMBAD names them as.`
|
||||
);
|
||||
|
||||
const merged = await mergeWithOtherSources(stars);
|
||||
const { stars: merged, folded } = foldByIdentity(await mergeWithOtherSources(stars), await glieseGaiaDesignations(rows));
|
||||
console.log(` ${folded} Gliese entries folded into the Gaia source SIMBAD names them as.`);
|
||||
merged.sort((a, b) => a.id - b.id);
|
||||
writeStarAssets(merged);
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** How far a naked-eye star may be from its Gaia source, and how much brighter or fainter in G. */
|
||||
const BRIGHT_COUNTERPART_TOLERANCE_RAD = (5 / 3600) * (Math.PI / 180);
|
||||
const BRIGHT_COUNTERPART_MAGNITUDES = 1;
|
||||
|
||||
/**
|
||||
* The Gaia source that is a naked-eye HYG star neither survey places otherwise: the nearest within
|
||||
* 5″ of its direction and a magnitude of its V. Found this way, HD 152249 is 4.1″ from where HYG
|
||||
* has it, the rest within 1.1″. At 15″, θ¹ Ori (HIP 26220) took the source of θ¹ Ori C, 12.9″ away
|
||||
* and already on the map.
|
||||
*/
|
||||
function brightCounterpart(row: Record<string, string>, magnitudeV: number, sources: readonly BrightGaiaSource[]): { distancePc: number; relativeError: number } | undefined {
|
||||
const [x, y, z] = [Number(row['x']), Number(row['y']), Number(row['z'])];
|
||||
const length = Math.hypot(x, y, z);
|
||||
let best: BrightGaiaSource | undefined;
|
||||
let bestCosine = Math.cos(BRIGHT_COUNTERPART_TOLERANCE_RAD);
|
||||
for (const source of sources) {
|
||||
const cosine = (x * source.direction.x + y * source.direction.y + z * source.direction.z) / length;
|
||||
if (cosine >= bestCosine && Math.abs(source.magnitudeG - magnitudeV) <= BRIGHT_COUNTERPART_MAGNITUDES) {
|
||||
best = source;
|
||||
bestCosine = cosine;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** HYG's Gliese designation as SIMBAD writes it: "Gl 734B" is "GJ 734 B". */
|
||||
function simbadGliese(gl: string): string {
|
||||
return gl.trim().replace(/^Gl\s+/, 'GJ ').replace(/^(GJ \d+(?:\.\d+)?)\s*([A-Z]+)$/, '$1 $2');
|
||||
}
|
||||
|
||||
/**
|
||||
* How many HYG rows with a Gliese number SIMBAD names a Gaia source for: 3 352 of HYG's 3 801 on
|
||||
* 2026-09-30. SIMBAD's side has its own floor; this one is on the join, where HYG's "Gl 94" has to
|
||||
* become SIMBAD's "GJ 94". Joined on HYG's names as they stand, 38 rows were folded instead of 49
|
||||
* and the check in build.ts, which takes this map too, still counted none left.
|
||||
*/
|
||||
const MIN_GLIESE_IDENTITIES = 3_200;
|
||||
|
||||
/**
|
||||
* The Gaia DR3 designation SIMBAD gives each HYG star with a Gliese number, by HYG id: what the
|
||||
* merge folds its Gliese-only rows by, and what build.ts checks it did. `rows` are HYG's, read
|
||||
* again from the cache when not given.
|
||||
*/
|
||||
export async function glieseGaiaDesignations(rows?: Record<string, string>[]): Promise<Map<number, string>> {
|
||||
const hyg = rows ?? parseCsvObjects(await fetchTextCached(HYG_CSV_URL, 'hygdata_v41.csv'));
|
||||
const byGj = await fetchGaiaDesignationsByGj();
|
||||
const designations = new Map<number, string>();
|
||||
for (const row of hyg) {
|
||||
const designation = row['gl'] ? byGj.get(simbadGliese(row['gl'])) : undefined;
|
||||
if (designation) {
|
||||
designations.set(Number(row['id']), designation);
|
||||
}
|
||||
}
|
||||
if (designations.size < MIN_GLIESE_IDENTITIES) {
|
||||
throw new Error(`Only ${designations.size} HYG stars with a Gliese number were matched to SIMBAD's (at least ${MIN_GLIESE_IDENTITIES} expected) — HYG's designations are no longer written as SIMBAD writes them.`);
|
||||
}
|
||||
return designations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unions HYG with every other positional source that is wired in and reachable.
|
||||
*
|
||||
@@ -183,7 +295,7 @@ async function mergeWithOtherSources(hygStars: StarRecord[]): Promise<StarRecord
|
||||
return stars;
|
||||
}
|
||||
|
||||
function writeStarAssets(stars: StarRecord[]): void {
|
||||
export function writeStarAssets(stars: StarRecord[]): void {
|
||||
ensureDataDir();
|
||||
|
||||
// The layout lives in `star-catalog.ts`, which the app decodes with — one definition, so the
|
||||
@@ -195,8 +307,9 @@ function writeStarAssets(stars: StarRecord[]): void {
|
||||
writeFileSync(dataPath('stars-index.json'), JSON.stringify(index));
|
||||
}
|
||||
|
||||
// On its own it writes what the ETL would, hosts included; imported late, since fetchExoplanets imports this module.
|
||||
if (require.main === module) {
|
||||
fetchStars().catch((error) => {
|
||||
import('./fetchExoplanets').then(({ fetchExoplanets }) => fetchExoplanets()).catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
+171
-24
@@ -70,6 +70,37 @@ function buildQuery(): string {
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* The nearby complement of the query above: every source it leaves out for being fainter than its
|
||||
* magnitude limit, or for having no G at all, out to 50 pc.
|
||||
*
|
||||
* The limit took a quarter of what lies within 10 pc — 84 of the 312 sources the Gaia Catalogue of
|
||||
* Nearby Stars (GCNS; Gaia Collaboration, Smart et al. 2021, A&A 649, A6) has there, Teegarden's
|
||||
* Star among them — and 28 012 within 50 pc: the red, brown and white dwarfs most of the Sun's
|
||||
* neighbourhood is made of, which the map only had where Gliese happened to list them. Further out
|
||||
* the same band is far too many rows to ship (G 12–13 alone is 188 408 within 250 pc).
|
||||
*
|
||||
* Only sources the GCNS keeps. Its classifier rejects 11 752 of the 39 764 that pass this parallax
|
||||
* floor, and the parallax error the main query filters on would keep all but 13 of them: they are
|
||||
* spurious parallaxes — median G 20.2 and 5.4 mas of astrometric excess noise, against 0.15 mas for
|
||||
* the ones it keeps; 6 933 of them toward the crowded Galactic centre. GCNS was built on EDR3,
|
||||
* whose astrometry and source ids DR3 carries unchanged. The error cut is kept for consistency; it
|
||||
* costs no GCNS source here, brown dwarfs included.
|
||||
*/
|
||||
const NEARBY_DISTANCE_PC = 50;
|
||||
|
||||
function buildNearbyQuery(): string {
|
||||
return [
|
||||
`select top ${ROW_LIMIT}`,
|
||||
'g.source_id, g.ra, g.dec, g.pmra, g.pmdec, g.parallax, g.parallax_error, g.phot_g_mean_mag, g.bp_rp',
|
||||
'from gaiadr3.gaia_source g join external.gaiaedr3_gcns_main_1 n on n.source_id = g.source_id',
|
||||
`where g.parallax > ${parallaxFloorMas(NEARBY_DISTANCE_PC).toFixed(6)}`,
|
||||
`and g.parallax_over_error > ${(1 / MAX_PARALLAX_ERROR_RATIO).toFixed(1)}`,
|
||||
`and (g.phot_g_mean_mag >= ${MAGNITUDE_LIMIT} or g.phot_g_mean_mag is null)`,
|
||||
'order by g.phot_g_mean_mag asc, g.source_id asc'
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* How many rows the query above holds when nothing is overridden: 412 765, and DR3 is a finished
|
||||
* data release, so that number only moves when the query does. It lives here, under the query, so
|
||||
@@ -87,6 +118,8 @@ function buildQuery(): string {
|
||||
* slice on purpose.
|
||||
*/
|
||||
const DEFAULT_QUERY_ROWS = 412_765;
|
||||
/** The same count for the nearby query, which the same truncation would cut from its faint end. */
|
||||
const DEFAULT_NEARBY_QUERY_ROWS = 28_012;
|
||||
const MIN_ROW_SHARE = 0.95;
|
||||
|
||||
/**
|
||||
@@ -100,9 +133,9 @@ const MIN_ROW_SHARE = 0.95;
|
||||
export class GaiaAnswerError extends Error {}
|
||||
|
||||
/**
|
||||
* Gaia publishes no spectral classifications, but `bp_rp` is a colour index on the same footing
|
||||
* as HYG's `ci` — so the app's existing colour and spectral-class handling works unchanged, and
|
||||
* the spectral type is left as unknown rather than invented from the colour.
|
||||
* Gaia publishes no spectral classifications. Its `bp_rp` is a colour index, though not HYG's
|
||||
* B−V — `colorSystem` says which — and the spectral type is left as unknown rather than
|
||||
* invented from it; the app estimates one, and says it is an estimate.
|
||||
*/
|
||||
const UNKNOWN_SPECTRAL_TYPE = 'Unknown';
|
||||
|
||||
@@ -113,31 +146,54 @@ const UNKNOWN_SPECTRAL_TYPE = 'Unknown';
|
||||
* the real identifier in the name.
|
||||
*/
|
||||
const GAIA_ID_BASE = 1_000_000_000;
|
||||
/**
|
||||
* Where the nearby query's ids start: fifty million past the main query's, and under 2^30, the
|
||||
* largest integer V8 keeps unboxed. The Int32 id column would take up to 2^31, but every id past
|
||||
* 2^30 is a heap number in the app: starting these at 2 000 000 000 made the boot task that
|
||||
* indexes the catalogue 230 ms longer (1.41 s against 1.18, medians of five interleaved runs).
|
||||
*/
|
||||
const NEARBY_ID_BASE = 1_050_000_000;
|
||||
|
||||
export async function fetchGaiaStars(): Promise<StarRecord[]> {
|
||||
const query = buildQuery();
|
||||
const url = `${GAIA_TAP_URL}?REQUEST=doQuery&LANG=ADQL&FORMAT=csv&QUERY=${encodeURIComponent(query)}`;
|
||||
const unchanged = MAGNITUDE_LIMIT === DEFAULT_MAGNITUDE_LIMIT && ROW_LIMIT === DEFAULT_ROW_LIMIT;
|
||||
console.log(`Fetching Gaia DR3 (within ${DISTANCE_CUTOFF_PC} pc, G < ${MAGNITUDE_LIMIT}, at most ${ROW_LIMIT} rows)...`);
|
||||
const rows = await fetchQueryRows(buildQuery(), unchanged && DISTANCE_CUTOFF_PC === DEFAULT_DISTANCE_CUTOFF_PC ? DEFAULT_QUERY_ROWS : undefined);
|
||||
console.log(`Fetching Gaia DR3's nearby complement (within ${NEARBY_DISTANCE_PC} pc, G >= ${MAGNITUDE_LIMIT} or none, kept by the GCNS)...`);
|
||||
const nearbyRows = await fetchQueryRows(buildNearbyQuery(), unchanged ? DEFAULT_NEARBY_QUERY_ROWS : undefined);
|
||||
|
||||
const stars = [...rowsToStars(rows, GAIA_ID_BASE), ...rowsToStars(nearbyRows, NEARBY_ID_BASE)];
|
||||
console.log(` kept ${stars.length} Gaia stars (of ${rows.length} + ${nearbyRows.length} rows).`);
|
||||
return stars;
|
||||
}
|
||||
|
||||
/**
|
||||
* One query's rows, refused when there are fewer than `expectedRows` — see
|
||||
* {@link DEFAULT_QUERY_ROWS} — or as many as the row limit.
|
||||
*/
|
||||
async function fetchQueryRows(query: string, expectedRows: number | undefined): Promise<Record<string, string>[]> {
|
||||
const url = `${GAIA_TAP_URL}?REQUEST=doQuery&LANG=ADQL&FORMAT=csv&QUERY=${encodeURIComponent(query)}`;
|
||||
// Keyed by the whole request, so a response cached for other columns, another order, or
|
||||
// another endpoint can never be mistaken for this one — the cache records only that some
|
||||
// response arrived, not what it answered.
|
||||
const cacheKey = `gaia-dr3-${createHash('sha1').update(url).digest('hex').slice(0, 8)}.csv`;
|
||||
const csv = await fetchTextCached(url, cacheKey);
|
||||
const rows = parseCsvObjects(csv);
|
||||
const jobsQuery = DISTANCE_CUTOFF_PC === DEFAULT_DISTANCE_CUTOFF_PC && MAGNITUDE_LIMIT === DEFAULT_MAGNITUDE_LIMIT && ROW_LIMIT === DEFAULT_ROW_LIMIT;
|
||||
if (jobsQuery && rows.length < DEFAULT_QUERY_ROWS * MIN_ROW_SHARE) {
|
||||
const rows = parseCsvObjects(await fetchTextCached(url, cacheKey));
|
||||
if (expectedRows !== undefined && rows.length < expectedRows * MIN_ROW_SHARE) {
|
||||
throw new GaiaAnswerError(
|
||||
`Gaia returned ${rows.length} rows, not the ~${DEFAULT_QUERY_ROWS} this query holds — the answer was cut short, it was an error page ` +
|
||||
`served with a 200, or the query was edited without updating DEFAULT_QUERY_ROWS; delete tools/etl/.cache/${cacheKey} once the archive answers properly`
|
||||
`Gaia returned ${rows.length} rows, not the ~${expectedRows} this query holds — the answer was cut short, it was an error page ` +
|
||||
`served with a 200, or the query was edited without updating its row count; delete tools/etl/.cache/${cacheKey} once the archive answers properly`
|
||||
);
|
||||
}
|
||||
// Not gated on `jobsQuery` like the floor above it: the only ways to reach this cap are the
|
||||
// overrides that *widen* the query, and they are exactly when it is worth saying. What it must
|
||||
// not fire on is a deliberately smaller slice, where filling the limit is the whole point.
|
||||
// Not gated like the floor above it: the only ways to reach this cap are the overrides that
|
||||
// *widen* the query, and they are exactly when it is worth saying. What it must not fire on is
|
||||
// a deliberately smaller slice, where filling the limit is the whole point.
|
||||
if (ROW_LIMIT >= DEFAULT_ROW_LIMIT && rows.length >= ROW_LIMIT) {
|
||||
throw new GaiaAnswerError(`Gaia returned the query's own ${ROW_LIMIT}-row limit, so it is the limit deciding what the map holds; raise ETL_GAIA_ROW_LIMIT.`);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Places each row at J2000.0, numbering them from `idBase` in the query's own order. */
|
||||
function rowsToStars(rows: readonly Record<string, string>[], idBase: number): StarRecord[] {
|
||||
const stars: StarRecord[] = [];
|
||||
|
||||
rows.forEach((row, index) => {
|
||||
@@ -153,22 +209,36 @@ export async function fetchGaiaStars(): Promise<StarRecord[]> {
|
||||
return;
|
||||
}
|
||||
|
||||
const j2000 = propagateProperMotion(raDeg, decDeg, parseOptionalNumber(row['pmra']) ?? 0, parseOptionalNumber(row['pmdec']) ?? 0, CATALOGUE_EPOCH - GAIA_DR3_EPOCH);
|
||||
const parallaxErrorMas = parseOptionalNumber(row['parallax_error']);
|
||||
const pmRaMasYr = parseOptionalNumber(row['pmra']);
|
||||
const pmDecMasYr = parseOptionalNumber(row['pmdec']);
|
||||
const j2000 = propagateProperMotion(raDeg, decDeg, pmRaMasYr ?? 0, pmDecMasYr ?? 0, CATALOGUE_EPOCH - GAIA_DR3_EPOCH);
|
||||
const { x, y, z } = raDegDecDistanceToXyz(j2000.raDeg, j2000.decDeg, distancePc);
|
||||
const magnitudeG = parseOptionalNumber(row['phot_g_mean_mag']);
|
||||
const colorIndex = parseOptionalNumber(row['bp_rp']);
|
||||
stars.push({
|
||||
id: GAIA_ID_BASE + index,
|
||||
id: idBase + index,
|
||||
name: `Gaia DR3 ${row['source_id']}`,
|
||||
gaiaDesignation: `Gaia DR3 ${row['source_id']}`,
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
magnitude: parseOptionalNumber(row['phot_g_mean_mag']) ?? MAGNITUDE_LIMIT,
|
||||
// Only the nearby query has sources without a G, 45 of them, and they are given its limit.
|
||||
// That errs bright: 26 of the 31 that 2MASS measured are at J 12.6–15.7, fainter still in G
|
||||
// for stars this red, and 5 at J 7–8.
|
||||
magnitude: magnitudeG ?? MAGNITUDE_LIMIT,
|
||||
...(magnitudeG === undefined ? {} : { magnitudeBand: 'G' as const }),
|
||||
spectralType: UNKNOWN_SPECTRAL_TYPE,
|
||||
colorIndex: parseOptionalNumber(row['bp_rp']) ?? null,
|
||||
source: 'gaia'
|
||||
colorIndex: colorIndex ?? null,
|
||||
...(colorIndex === undefined ? {} : { colorSystem: 'BP-RP' as const }),
|
||||
...(parallaxErrorMas === undefined ? {} : { distanceError: parallaxErrorMas / parallaxMas }),
|
||||
distanceFromGaia: true,
|
||||
source: 'gaia',
|
||||
pmRaMasYr,
|
||||
pmDecMasYr
|
||||
});
|
||||
});
|
||||
|
||||
console.log(` kept ${stars.length} Gaia stars (of ${rows.length} rows).`);
|
||||
return stars;
|
||||
}
|
||||
|
||||
@@ -181,7 +251,8 @@ export async function fetchGaiaStars(): Promise<StarRecord[]> {
|
||||
const MIN_USABLE_HIP_DISTANCES = 90_000;
|
||||
|
||||
/**
|
||||
* Gaia's distance for every Hipparcos star it has a usable parallax for, keyed by HIP number.
|
||||
* Gaia's distance for every Hipparcos star it has a usable parallax for, and that distance's
|
||||
* relative error, keyed by HIP number.
|
||||
*
|
||||
* Taken from the archive's own cross-match (`hipparcos2_best_neighbour`) rather than from
|
||||
* matching positions here, since Gaia's team made that identification star by star with the
|
||||
@@ -193,7 +264,7 @@ const MIN_USABLE_HIP_DISTANCES = 90_000;
|
||||
* distance, the 6 833 that Gaia puts past 250 pc move back inside, and the published map would
|
||||
* flip between the two with the archive's availability.
|
||||
*/
|
||||
export async function fetchGaiaDistancesByHip(): Promise<Map<number, number>> {
|
||||
export async function fetchGaiaDistancesByHip(): Promise<Map<number, { distancePc: number; relativeError: number }>> {
|
||||
const query = [
|
||||
'select top 200000 b.original_ext_source_id as hip, g.parallax, g.parallax_over_error',
|
||||
'from gaiadr3.hipparcos2_best_neighbour b join gaiadr3.gaia_source g on g.source_id = b.source_id'
|
||||
@@ -202,13 +273,13 @@ export async function fetchGaiaDistancesByHip(): Promise<Map<number, number>> {
|
||||
console.log('Fetching Gaia DR3 distances for Hipparcos stars (archive cross-match)...');
|
||||
const rows = parseCsvObjects(await fetchTextCached(url, `gaia-dr3-hip-${createHash('sha1').update(url).digest('hex').slice(0, 8)}.csv`));
|
||||
|
||||
const distances = new Map<number, number>();
|
||||
const distances = new Map<number, { distancePc: number; relativeError: number }>();
|
||||
for (const row of rows) {
|
||||
const hip = parseOptionalNumber(row['hip']);
|
||||
const parallaxMas = parseOptionalNumber(row['parallax']);
|
||||
const overError = parseOptionalNumber(row['parallax_over_error']);
|
||||
if (hip !== undefined && parallaxMas !== undefined && parallaxMas > 0 && overError !== undefined && overError > 1 / MAX_PARALLAX_ERROR_RATIO) {
|
||||
distances.set(hip, 1000 / parallaxMas);
|
||||
distances.set(hip, { distancePc: 1000 / parallaxMas, relativeError: 1 / overError });
|
||||
}
|
||||
}
|
||||
if (distances.size < MIN_USABLE_HIP_DISTANCES) {
|
||||
@@ -220,3 +291,79 @@ export async function fetchGaiaDistancesByHip(): Promise<Map<number, number>> {
|
||||
console.log(` ${distances.size} Hipparcos stars have a Gaia distance (of ${rows.length} cross-matched).`);
|
||||
return distances;
|
||||
}
|
||||
|
||||
/**
|
||||
* How bright a Gaia source must be to stand in for a naked-eye HYG star, in G, and how many the
|
||||
* archive holds that bright with a usable parallax: 35 910 on 2026-09-30.
|
||||
*/
|
||||
const BRIGHT_MAGNITUDE_LIMIT = 7.5;
|
||||
const BRIGHT_QUERY_ROWS = 35_910;
|
||||
|
||||
/** A bright Gaia source's designation, J2000 direction, G magnitude and distance. */
|
||||
export interface BrightGaiaSource {
|
||||
designation: string;
|
||||
direction: { x: number; y: number; z: number };
|
||||
magnitudeG: number;
|
||||
distancePc: number;
|
||||
relativeError: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every Gaia DR3 source brighter than G 7.5 with a usable parallax, at J2000: for the naked-eye
|
||||
* HYG stars nothing else places, found by position. The Hipparcos cross-match above lacks some of
|
||||
* their HIP numbers, and the HYG rows with no HIP number were never looked up at all. Of the 34
|
||||
* HYG stars of V 6.5 or brighter left off the map for want of a distance, 12 are found here: ten
|
||||
* that were missing, 66 Ori and HD 197770 (1.102 ± 0.029 mas) among them, and HD 45291 and
|
||||
* HD 124953, which were on the map only as bare Gaia entries a second of arc from where HYG has them.
|
||||
*/
|
||||
export async function fetchBrightGaiaSources(): Promise<BrightGaiaSource[]> {
|
||||
const query = [
|
||||
`select top ${ROW_LIMIT} source_id, ra, dec, pmra, pmdec, parallax, parallax_over_error, phot_g_mean_mag`,
|
||||
'from gaiadr3.gaia_source',
|
||||
`where phot_g_mean_mag < ${BRIGHT_MAGNITUDE_LIMIT} and parallax_over_error > ${(1 / MAX_PARALLAX_ERROR_RATIO).toFixed(1)}`,
|
||||
'order by source_id'
|
||||
].join(' ');
|
||||
console.log(`Fetching Gaia DR3's sources brighter than G ${BRIGHT_MAGNITUDE_LIMIT}, for the naked-eye stars the cross-match lacks...`);
|
||||
const rows = await fetchQueryRows(query, BRIGHT_QUERY_ROWS);
|
||||
const sources: BrightGaiaSource[] = [];
|
||||
for (const row of rows) {
|
||||
const [raDeg, decDeg, parallaxMas, overError, magnitudeG] = ['ra', 'dec', 'parallax', 'parallax_over_error', 'phot_g_mean_mag'].map((column) => parseOptionalNumber(row[column]));
|
||||
if (raDeg === undefined || decDeg === undefined || !parallaxMas || parallaxMas <= 0 || overError === undefined || magnitudeG === undefined) {
|
||||
continue;
|
||||
}
|
||||
const j2000 = propagateProperMotion(raDeg, decDeg, parseOptionalNumber(row['pmra']) ?? 0, parseOptionalNumber(row['pmdec']) ?? 0, CATALOGUE_EPOCH - GAIA_DR3_EPOCH);
|
||||
sources.push({ designation: `Gaia DR3 ${row['source_id']}`, direction: raDegDecDistanceToXyz(j2000.raDeg, j2000.decDeg, 1), magnitudeG, distancePc: 1000 / parallaxMas, relativeError: 1 / overError });
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
/** The new Hipparcos reduction holds 117 955 stars, and every one has a parallax error. */
|
||||
const MIN_HIPPARCOS_ERRORS = 110_000;
|
||||
|
||||
/**
|
||||
* Every Hipparcos parallax with its relative error, keyed by HIP number: to choose between it and
|
||||
* Gaia's, and for the stars that keep their Hipparcos distance — Rigel and Deneb, which Gaia has
|
||||
* no usable parallax for, and the few hundred bright stars where Gaia's is the less precise.
|
||||
*
|
||||
* From van Leeuwen's 2007 reduction, which the ESA archive hosts beside Gaia and HYG's distances
|
||||
* are the inverse of. HYG publishes the distance and not its error.
|
||||
*/
|
||||
export async function fetchHipparcosParallaxErrors(): Promise<Map<number, { parallaxMas: number; relativeError: number }>> {
|
||||
const url = `${GAIA_TAP_URL}?REQUEST=doQuery&LANG=ADQL&FORMAT=csv&QUERY=${encodeURIComponent('select top 200000 hip, plx, e_plx from public.hipparcos_newreduction order by hip')}`;
|
||||
console.log('Fetching Hipparcos parallax errors (new reduction)...');
|
||||
const rows = parseCsvObjects(await fetchTextCached(url, `hipparcos-errors-${createHash('sha1').update(url).digest('hex').slice(0, 8)}.csv`));
|
||||
|
||||
const errors = new Map<number, { parallaxMas: number; relativeError: number }>();
|
||||
for (const row of rows) {
|
||||
const hip = parseOptionalNumber(row['hip']);
|
||||
const parallaxMas = parseOptionalNumber(row['plx']);
|
||||
const errorMas = parseOptionalNumber(row['e_plx']);
|
||||
if (hip !== undefined && parallaxMas !== undefined && parallaxMas > 0 && errorMas !== undefined) {
|
||||
errors.set(hip, { parallaxMas, relativeError: errorMas / parallaxMas });
|
||||
}
|
||||
}
|
||||
if (errors.size < MIN_HIPPARCOS_ERRORS) {
|
||||
throw new Error(`the Hipparcos reduction gave ${errors.size} parallax errors (of ${rows.length} rows), not the ~117 955 it holds; delete tools/etl/.cache/hipparcos-errors-*.csv once the archive answers properly`);
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import { parseCsvObjects } from '../lib/csv';
|
||||
import { fetchTextCached } from '../lib/http';
|
||||
|
||||
/**
|
||||
* SIMBAD, via its TAP service, for one thing: which Gaia DR3 source a star is.
|
||||
*
|
||||
* The merge decides identity on the sky, and for the HYG rows Gliese places without Hipparcos
|
||||
* astrometry the sky is not enough: their positions are off by up to minutes of arc, their
|
||||
* distances are photometric and their proper motions sometimes wrong. SIMBAD's cross-identifications
|
||||
* were made star by star, and name the answer.
|
||||
*/
|
||||
|
||||
const SIMBAD_TAP_URL = 'https://simbad.cds.unistra.fr/simbad/sim-tap/sync';
|
||||
|
||||
/** SIMBAD gave 4 868 GJ designations a Gaia DR3 one on 2026-09-30. */
|
||||
const MIN_GJ_DESIGNATIONS = 4_000;
|
||||
|
||||
/**
|
||||
* SIMBAD's Gaia DR3 designation for every object it knows by a Gliese-Jahreiss number, keyed by
|
||||
* that number with its spaces collapsed, as SIMBAD writes it: "GJ 734 B", not HYG's "Gl 734B".
|
||||
* One query for the whole catalogue rather than for the rows that need it, so the answer, and its
|
||||
* cache, do not change when those rows do.
|
||||
*/
|
||||
export async function fetchGaiaDesignationsByGj(): Promise<Map<string, string>> {
|
||||
const designations = await fetchDesignations(
|
||||
"select i1.id as gj, i2.id as gaia from ident as i1 join ident as i2 on i1.oidref = i2.oidref where i1.id like 'GJ %' and i2.id like 'Gaia DR3 %' order by gj, gaia",
|
||||
'gj',
|
||||
'simbad-gj-gaia'
|
||||
);
|
||||
if (designations.size < MIN_GJ_DESIGNATIONS) {
|
||||
throw new Error(`SIMBAD gave ${designations.size} GJ stars a Gaia DR3 designation, not the ~4 868 it holds; delete tools/etl/.cache/simbad-gj-gaia-*.csv once it answers properly`);
|
||||
}
|
||||
return designations;
|
||||
}
|
||||
|
||||
/** Of the 206 HD numbers asked for below, SIMBAD gave 199 a Gaia DR3 designation on 2026-09-30. */
|
||||
const MIN_HD_DESIGNATION_SHARE = 0.9;
|
||||
|
||||
/**
|
||||
* SIMBAD's Gaia DR3 designation for each of `hdNumbers`, keyed "HD 45951": for the naked-eye HYG
|
||||
* rows with no distance, which HYG's own position does not always lead to — HD 45951's declination
|
||||
* there is 31.7′ off SIMBAD's. Asked by number, since the HD catalogue whole would be 360 000 rows;
|
||||
* the numbers are HYG's, so the answer and its cache change only when HYG does.
|
||||
*/
|
||||
export async function fetchGaiaDesignationsByHd(hdNumbers: readonly string[]): Promise<Map<string, string>> {
|
||||
const list = [...hdNumbers].sort().map((hd) => `'HD ${hd}'`).join(', ');
|
||||
const designations = await fetchDesignations(
|
||||
`select i1.id as hd, i2.id as gaia from ident as i1 join ident as i2 on i1.oidref = i2.oidref where i1.id in (${list}) and i2.id like 'Gaia DR3 %' order by hd, gaia`,
|
||||
'hd',
|
||||
'simbad-hd-gaia'
|
||||
);
|
||||
if (designations.size < hdNumbers.length * MIN_HD_DESIGNATION_SHARE) {
|
||||
throw new Error(`SIMBAD gave ${designations.size} of ${hdNumbers.length} HD stars a Gaia DR3 designation; delete tools/etl/.cache/simbad-hd-gaia-*.csv once it answers properly`);
|
||||
}
|
||||
return designations;
|
||||
}
|
||||
|
||||
/** The identifier in `column` → Gaia DR3 designation pairs a query answers, SIMBAD's padding collapsed ("HD 45951"). */
|
||||
async function fetchDesignations(query: string, column: string, cachePrefix: string): Promise<Map<string, string>> {
|
||||
const url = `${SIMBAD_TAP_URL}?REQUEST=doQuery&LANG=ADQL&FORMAT=csv&QUERY=${encodeURIComponent(query)}`;
|
||||
const rows = parseCsvObjects(await fetchTextCached(url, `${cachePrefix}-${createHash('sha1').update(url).digest('hex').slice(0, 8)}.csv`));
|
||||
|
||||
const designations = new Map<string, string>();
|
||||
for (const row of rows) {
|
||||
const identifier = collapse(row[column] ?? '');
|
||||
// An object SIMBAD gives two Gaia DR3 sources is one it has not resolved; neither is the star.
|
||||
designations.set(identifier, designations.has(identifier) ? '' : collapse(row['gaia'] ?? ''));
|
||||
}
|
||||
for (const [identifier, designation] of designations) {
|
||||
if (!designation) {
|
||||
designations.delete(identifier);
|
||||
}
|
||||
}
|
||||
return designations;
|
||||
}
|
||||
|
||||
function collapse(identifier: string): string {
|
||||
return identifier.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
Reference in New Issue
Block a user