Derive a surface for every body that was never photographed

Fifteen bodies here have a real photograph. Every exoplanet does not, and
never will on current instruments — none has ever been imaged — and nor do
several of the solar system's own moons. Those all shared one crude
stand-in: a few noisy bands tinted by category, cached per colour, so
every exoplanet in the app was literally the same picture.

They now get a surface reasoned from what has actually been measured.

The chain is standard at every link. A host star's luminosity comes from
its catalogued apparent magnitude and its parallax distance — that pair is
exactly an absolute magnitude — plus a bolometric correction for its
spectral class. The correction is not optional: an M dwarf radiates most
of its light in the infrared, so its visual magnitude understates it more
than tenfold, and M dwarfs are what most nearby planet hosts are.
Luminosity and the semi-major axis then give an equilibrium temperature,
mass and radius give a bulk density, and size, temperature and density
together give a class of world.

Checked against the solar system the temperatures land on Earth 255 K,
Jupiter 112 K, Neptune 46 K, all within a kelvin or two of published
values, and 51 Pegasi b comes out at 1227 K against a published 1200.

Each class carries a palette reasoned from its chemistry — methane absorbs
red light, which is why the ice giants are blue — and a structure: zonal
bands for a body with a fluid envelope, because a rapidly rotating
atmosphere organises into them, and fractal terrain for one with a solid
surface. Polar caps grow and shrink with the derived temperature, which is
the clearest visible consequence of the whole chain.

The generator samples three-dimensional noise along the sphere rather than
a flat field, so there is no seam to stitch at the antimeridian and no
pinching at the poles, and it writes into a byte array rather than a
canvas — a pure function, testable, with no 2D context to be unavailable.

Two things the derivation cannot do, both stated on screen next to the
measurements it rests on. Equilibrium temperature ignores greenhouse
warming and internal heat, so Venus comes out at 300 K against a real
surface of 737 K and Io, kept molten by tides, classifies as ice. And
these are illustrations: reasoned, but not observations.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WaySiNst4HhDXBHnMy8p5G
This commit is contained in:
Claude
2026-08-05 06:52:22 +00:00
parent a84e2d3a69
commit ac296f5133
17 changed files with 1566 additions and 92 deletions
@@ -0,0 +1,94 @@
import { describe, expect, it } from 'vitest';
import { BodyRecord } from '../models/body.model';
import { ExoplanetRecord } from '../models/exoplanet.model';
import { appearanceForBody, appearanceForExoplanet, heliocentricDistanceAu } from './body-appearance';
import { DEFAULT_EPOCH_JD } from './constants';
const ORBIT = { eccentricity: 0, inclinationDeg: 0, longitudeOfAscendingNodeDeg: 0, argumentOfPeriapsisDeg: 0, meanAnomalyAtEpochDeg: 0, epochJd: DEFAULT_EPOCH_JD };
const JUPITER: BodyRecord = { id: 'jupiter', systemStarId: 0, name: 'Jupiter', kind: 'planet', radiusKm: 69911, orbit: { ...ORBIT, semiMajorAxisAu: 5.204 } };
/** Europa's own orbit is around Jupiter: 671,000 km, which is 0.00449 AU. */
const EUROPA: BodyRecord = { id: 'europa', systemStarId: 0, name: 'Europa', kind: 'moon', radiusKm: 1560, parentBodyId: 'jupiter', orbit: { ...ORBIT, semiMajorAxisAu: 0.00449 } };
const EARTH: BodyRecord = { id: 'earth', systemStarId: 0, name: 'Earth', kind: 'planet', radiusKm: 6371, orbit: { ...ORBIT, semiMajorAxisAu: 1 } };
const ORPHAN: BodyRecord = { ...EUROPA, id: 'orphan', parentBodyId: 'nowhere' };
const BODIES = [JUPITER, EUROPA, EARTH, ORPHAN];
describe('heliocentricDistanceAu', () => {
it('uses a planet own orbit', () => {
expect(heliocentricDistanceAu(JUPITER, BODIES)).toBeCloseTo(5.204, 6);
});
it('uses a moon parent orbit, not the moon own', () => {
// The load-bearing case: Europa's own semi-major axis is 0.0045 AU. Fed to an equilibrium
// temperature it would put Europa closer to the Sun than Mercury and boil it.
expect(heliocentricDistanceAu(EUROPA, BODIES)).toBeCloseTo(5.204, 6);
});
it('has no answer for a moon whose parent is missing', () => {
expect(heliocentricDistanceAu(ORPHAN, BODIES)).toBeUndefined();
});
});
describe('appearanceForBody', () => {
it('derives an ice world for a moon of Jupiter, at Jupiter distance', () => {
const europa = appearanceForBody(EUROPA, BODIES, 1);
expect(europa.equilibriumTemperatureK).toBeCloseTo(112, -0.5);
expect(europa.planetClass).toBe('icy');
});
it('would have melted that same moon if it used the moon own orbit', () => {
// Pinning the bug the parent lookup exists to avoid, so it cannot come back silently.
const wrong = appearanceForBody({ ...EUROPA, parentBodyId: undefined }, BODIES, 1);
expect(wrong.equilibriumTemperatureK!).toBeGreaterThan(2000);
expect(wrong.planetClass).not.toBe('icy');
});
it('derives Earth as temperate with a polar cap', () => {
const earth = appearanceForBody(EARTH, BODIES, 1);
expect(earth.planetClass).toBe('temperate');
expect(earth.equilibriumTemperatureK).toBeCloseTo(255, -0.5);
expect(earth.polarCapExtentDeg).toBeGreaterThan(0);
});
it('has no density for a solar-system body, since Horizons publishes no masses', () => {
expect(appearanceForBody(EARTH, BODIES, 1).bulkDensityGramsPerCm3).toBeNull();
});
it('still classifies a body when the host luminosity is unknown', () => {
const earth = appearanceForBody(EARTH, BODIES, null);
expect(earth.equilibriumTemperatureK).toBeNull();
expect(earth.planetClass).toBe('rocky');
});
});
describe('appearanceForExoplanet', () => {
const KEPLER_186F: ExoplanetRecord = { id: 'Kepler-186 f', hostStarId: 1, hostStarName: 'Kepler-186', name: 'Kepler-186 f', radiusEarth: 1.17, orbit: { semiMajorAxisAu: 0.432 } };
it('derives a temperature from the host star output and the published orbit', () => {
// A quarter of a solar luminosity at 0.432 AU: cool, but not frozen.
const derived = appearanceForExoplanet(KEPLER_186F, 0.04);
expect(derived.equilibriumTemperatureK).toBeGreaterThan(150);
expect(derived.equilibriumTemperatureK).toBeLessThan(250);
});
it('derives a density where both a radius and a mass are published', () => {
const withMass = appearanceForExoplanet({ ...KEPLER_186F, massEarth: 1.4 }, 0.04);
expect(withMass.bulkDensityGramsPerCm3).toBeCloseTo((5.51 * 1.4) / Math.pow(1.17, 3), 4);
});
it('falls back to size alone for a host that never cross-referenced to the catalogue', () => {
// 5685 of the 6319 archive records have no matching HYG star. None of them is rendered in a
// system, but any of them can still be opened from search.
const derived = appearanceForExoplanet({ ...KEPLER_186F, hostStarId: null }, null);
expect(derived.equilibriumTemperatureK).toBeNull();
expect(derived.planetClass).toBe('rocky');
});
it('is stable per planet, so a world keeps its face between visits', () => {
expect(appearanceForExoplanet(KEPLER_186F, 0.04).seed).toBe(appearanceForExoplanet(KEPLER_186F, 0.04).seed);
});
});
+55
View File
@@ -0,0 +1,55 @@
import { BodyRecord } from '../models/body.model';
import { ExoplanetRecord } from '../models/exoplanet.model';
import { EARTH_RADIUS_KM, PlanetAppearance, planetAppearance } from './planet-appearance';
/**
* Adapters from the two record shapes this app carries to the appearance derivation.
*
* They exist because the two sources publish different things. The Exoplanet Archive gives a
* radius and a mass in Earth units and a semi-major axis around the host star. Horizons gives a
* radius in kilometres, no mass at all, and — for a moon — a semi-major axis around its
* *planet* rather than around the Sun. Feeding a moon's own orbit into an equilibrium
* temperature would put Europa a few thousandths of an AU from the Sun and melt it.
*/
/**
* Distance from the host star at which a body actually sits, in AU.
*
* For a moon that is its planet's distance, not its own: the tiny orbit around the planet is
* irrelevant to how much starlight reaches it, and using it would be off by three orders of
* magnitude.
*/
export function heliocentricDistanceAu(body: BodyRecord, bodies: readonly BodyRecord[]): number | undefined {
if (!body.parentBodyId) {
return body.orbit.semiMajorAxisAu;
}
return bodies.find((candidate) => candidate.id === body.parentBodyId)?.orbit.semiMajorAxisAu;
}
/**
* Appearance of a solar-system body.
*
* Horizons publishes no masses, so these worlds have no derived density and are classified on
* size and temperature alone. That is enough for what it decides here: at Jupiter's distance
* from the Sun the question is never whether a moon is rock or iron, it is whether its surface
* is ice, and the temperature answers that on its own.
*/
export function appearanceForBody(body: BodyRecord, bodies: readonly BodyRecord[], hostLuminositySolar: number | null | undefined): PlanetAppearance {
return planetAppearance({
id: body.id,
radiusEarth: body.radiusKm ? body.radiusKm / EARTH_RADIUS_KM : undefined,
semiMajorAxisAu: heliocentricDistanceAu(body, bodies),
hostLuminositySolar
});
}
/** Appearance of an exoplanet, from the archive's published radius, mass and orbit. */
export function appearanceForExoplanet(exoplanet: ExoplanetRecord, hostLuminositySolar: number | null | undefined): PlanetAppearance {
return planetAppearance({
id: exoplanet.id,
radiusEarth: exoplanet.radiusEarth,
massEarth: exoplanet.massEarth,
semiMajorAxisAu: exoplanet.orbit.semiMajorAxisAu,
hostLuminositySolar
});
}
@@ -0,0 +1,257 @@
import { describe, expect, it } from 'vitest';
import {
bulkDensityGramsPerCm3,
classifyPlanet,
EARTH_DENSITY_G_PER_CM3,
equilibriumTemperatureK,
paletteFor,
planetAppearance,
PLANET_CLASS_LABELS,
PlanetClass,
polarCapExtentDeg,
seedFromId
} from './planet-appearance';
const EARTH_RADII = { mercury: 0.383, venus: 0.949, earth: 1, mars: 0.532, jupiter: 10.97, saturn: 9.14, uranus: 3.98, neptune: 3.86, europa: 0.245, phobos: 0.0017 };
describe('bulkDensityGramsPerCm3', () => {
it('gives Earth its own density, by construction', () => {
expect(bulkDensityGramsPerCm3(1, 1)).toBeCloseTo(EARTH_DENSITY_G_PER_CM3, 9);
});
it('separates a ball of iron from a ball of hydrogen, which is what it is for', () => {
// Mercury is 5.4 g/cm3 and mostly core; Saturn is 0.69 and would float.
expect(bulkDensityGramsPerCm3(0.055, EARTH_RADII.mercury)).toBeCloseTo(5.4, 0);
expect(bulkDensityGramsPerCm3(95.2, EARTH_RADII.saturn)).toBeCloseTo(0.69, 1);
});
it('has no answer without both numbers', () => {
expect(bulkDensityGramsPerCm3(undefined, 1)).toBeNull();
expect(bulkDensityGramsPerCm3(1, undefined)).toBeNull();
expect(bulkDensityGramsPerCm3(0, 1)).toBeNull();
expect(bulkDensityGramsPerCm3(1, -1)).toBeNull();
});
});
describe('equilibriumTemperatureK', () => {
// The published equilibrium temperatures, which this must reproduce to be worth anything.
it.each([
['Earth', 1, 1, 255],
['Mars', 1, 1.524, 206],
['Jupiter', 1, 5.204, 112],
['Neptune', 1, 30.07, 46]
])('reproduces the published equilibrium temperature of %s', (_name, luminosity, semiMajorAxisAu, expected) => {
expect(equilibriumTemperatureK(luminosity, semiMajorAxisAu)).toBeCloseTo(expected, -0.5);
});
it('follows the inverse square root of distance', () => {
const near = equilibriumTemperatureK(1, 1)!;
const far = equilibriumTemperatureK(1, 4)!;
expect(near / far).toBeCloseTo(2, 6);
});
it('follows the fourth root of luminosity, which is why a rough luminosity still serves', () => {
const dim = equilibriumTemperatureK(1, 1)!;
const bright = equilibriumTemperatureK(16, 1)!;
expect(bright / dim).toBeCloseTo(2, 6);
});
it('puts a hot Jupiter where a hot Jupiter is', () => {
// 51 Pegasi b: 0.052 AU from a slightly super-solar star, published near 1200 K.
expect(equilibriumTemperatureK(1.3, 0.052)!).toBeGreaterThan(1000);
});
it('cools a world as its albedo rises, as a fourth root', () => {
expect(equilibriumTemperatureK(1, 1, 0.8)!).toBeLessThan(equilibriumTemperatureK(1, 1, 0)!);
});
it('has no answer without a star or an orbit', () => {
expect(equilibriumTemperatureK(null, 1)).toBeNull();
expect(equilibriumTemperatureK(1, undefined)).toBeNull();
expect(equilibriumTemperatureK(0, 1)).toBeNull();
});
});
describe('classifyPlanet', () => {
/** Every solar-system body this app carries, at its real size and equilibrium temperature. */
it.each<[string, { radiusEarth?: number; massEarth?: number; equilibriumTemperatureK?: number }, PlanetClass]>([
['Mercury', { radiusEarth: EARTH_RADII.mercury, massEarth: 0.055, equilibriumTemperatureK: 410 }, 'scorched'],
['Earth', { radiusEarth: 1, massEarth: 1, equilibriumTemperatureK: 255 }, 'temperate'],
['Mars', { radiusEarth: EARTH_RADII.mars, massEarth: 0.107, equilibriumTemperatureK: 206 }, 'temperate'],
['Jupiter', { radiusEarth: EARTH_RADII.jupiter, massEarth: 317.8, equilibriumTemperatureK: 112 }, 'gasGiant'],
['Saturn', { radiusEarth: EARTH_RADII.saturn, massEarth: 95.2, equilibriumTemperatureK: 82 }, 'gasGiant'],
['Uranus', { radiusEarth: EARTH_RADII.uranus, massEarth: 14.5, equilibriumTemperatureK: 58 }, 'iceGiant'],
['Neptune', { radiusEarth: EARTH_RADII.neptune, massEarth: 17.1, equilibriumTemperatureK: 46 }, 'iceGiant'],
['Europa', { radiusEarth: EARTH_RADII.europa, equilibriumTemperatureK: 112 }, 'icy'],
['51 Peg b', { massEarth: 193.9, equilibriumTemperatureK: 1227 }, 'hotGasGiant'],
['GJ 1214 b', { radiusEarth: 2.733, massEarth: 8.4, equilibriumTemperatureK: 596 }, 'subNeptune']
])('puts %s in the right class', (_name, measurements, expected) => {
expect(classifyPlanet(measurements)).toBe(expected);
});
it('tells a gas giant from an ice giant by size, since temperature cannot', () => {
// Jupiter is 110 K and Neptune is 47 K: both freezing, and the difference between them is
// how much hydrogen they hold, not how cold they are.
const cold = { equilibriumTemperatureK: 100 };
expect(classifyPlanet({ ...cold, radiusEarth: EARTH_RADII.jupiter })).toBe('gasGiant');
expect(classifyPlanet({ ...cold, radiusEarth: EARTH_RADII.neptune })).toBe('iceGiant');
});
it('calls any giant hot once it is hot, whichever kind it was', () => {
for (const radiusEarth of [EARTH_RADII.jupiter, EARTH_RADII.neptune]) {
expect(classifyPlanet({ radiusEarth, equilibriumTemperatureK: 1400 })).toBe('hotGasGiant');
}
});
it('lets density override temperature at both extremes', () => {
// Iron whatever the weather...
expect(classifyPlanet({ radiusEarth: 1, massEarth: 1.6, equilibriumTemperatureK: 255 })).toBe('iron');
// ...and too light to be rock means ice, even where rock would be solid.
expect(classifyPlanet({ radiusEarth: 1.5, massEarth: 1, equilibriumTemperatureK: 250 })).toBe('icy');
});
it('melts a rocky world that is hot enough', () => {
expect(classifyPlanet({ radiusEarth: 1, equilibriumTemperatureK: 1500 })).toBe('lava');
});
it('refuses to call a 11 km moon temperate on the strength of its orbital distance', () => {
// Phobos sits at Mars's distance and so at Mars's temperature, and is an airless rock.
expect(classifyPlanet({ radiusEarth: EARTH_RADII.phobos, equilibriumTemperatureK: 206 })).toBe('rocky');
});
it('falls back to size alone when the host star is unknown', () => {
expect(classifyPlanet({ radiusEarth: 1 })).toBe('rocky');
expect(classifyPlanet({ radiusEarth: EARTH_RADII.jupiter })).toBe('gasGiant');
expect(classifyPlanet({ radiusEarth: 2.5 })).toBe('subNeptune');
});
it('classifies from a mass alone, for the planets only radial velocity has seen', () => {
expect(classifyPlanet({ massEarth: 300 })).toBe('gasGiant');
expect(classifyPlanet({ massEarth: 15 })).toBe('iceGiant');
expect(classifyPlanet({ massEarth: 4 })).toBe('subNeptune');
expect(classifyPlanet({ massEarth: 1 })).toBe('rocky');
});
it('prefers radius over mass, since radius is what the classes are defined by', () => {
// A puffy planet as massive as Neptune but the size of Jupiter is a gas giant.
expect(classifyPlanet({ radiusEarth: EARTH_RADII.jupiter, massEarth: 15 })).toBe('gasGiant');
});
it('always returns a class, whatever it is given', () => {
expect(classifyPlanet({})).toBe('rocky');
});
});
describe('polarCapExtentDeg', () => {
it('grows caps as a world cools, which is the visible consequence of the derived temperature', () => {
const warm = polarCapExtentDeg('temperate', 280)!;
const cool = polarCapExtentDeg('temperate', 230)!;
const cold = polarCapExtentDeg('temperate', 190)!;
expect(warm).toBeLessThan(cool);
expect(cool).toBeLessThan(cold);
});
it('covers a frozen world entirely and leaves a warm one bare', () => {
expect(polarCapExtentDeg('icy', 100)).toBe(90);
expect(polarCapExtentDeg('rocky', 400)).toBeNull();
});
it('gives Earth a cap that stops well short of the tropics', () => {
const earth = polarCapExtentDeg('temperate', 255)!;
expect(earth).toBeGreaterThan(5);
expect(earth).toBeLessThan(45);
});
it('does not put ice on a world where ice is not the question', () => {
for (const planetClass of ['gasGiant', 'hotGasGiant', 'iceGiant', 'subNeptune', 'lava'] as PlanetClass[]) {
expect(polarCapExtentDeg(planetClass, 100)).toBeNull();
}
});
it('has no answer without a temperature', () => {
expect(polarCapExtentDeg('temperate', null)).toBeNull();
expect(polarCapExtentDeg('temperate', undefined)).toBeNull();
});
});
describe('paletteFor', () => {
const ALL_CLASSES = Object.keys(PLANET_CLASS_LABELS) as PlanetClass[];
it('has a palette and a label for every class', () => {
for (const planetClass of ALL_CLASSES) {
expect(paletteFor(planetClass)).toBeDefined();
expect(PLANET_CLASS_LABELS[planetClass].length).toBeGreaterThan(0);
}
});
it('keeps every channel inside the displayable range', () => {
for (const planetClass of ALL_CLASSES) {
const palette = paletteFor(planetClass);
for (const tone of [palette.low, palette.mid, palette.high, palette.cap]) {
for (const channel of tone) {
expect(channel).toBeGreaterThanOrEqual(0);
expect(channel).toBeLessThanOrEqual(1);
}
}
expect(palette.contrast).toBeGreaterThan(0);
expect(palette.contrast).toBeLessThanOrEqual(1);
}
});
it('bands the worlds with a fluid envelope and gives terrain to the ones with a surface', () => {
for (const planetClass of ['gasGiant', 'hotGasGiant', 'iceGiant', 'subNeptune'] as PlanetClass[]) {
expect(paletteFor(planetClass).structure).toBe('banded');
}
for (const planetClass of ['lava', 'scorched', 'iron', 'rocky', 'temperate', 'icy'] as PlanetClass[]) {
expect(paletteFor(planetClass).structure).toBe('terrain');
}
});
it('makes an ice giant blue and a hot giant red, following what each is made of', () => {
// Methane absorbs red light, which is exactly why Uranus and Neptune look the way they do.
const iceGiant = paletteFor('iceGiant').mid;
expect(iceGiant[2]).toBeGreaterThan(iceGiant[0]);
const hot = paletteFor('hotGasGiant').mid;
expect(hot[0]).toBeGreaterThan(hot[2]);
});
});
describe('seedFromId', () => {
it('is stable, so a world looks the same on every visit', () => {
expect(seedFromId('Kepler-186 f')).toBe(seedFromId('Kepler-186 f'));
});
it('separates bodies that differ only slightly in name', () => {
expect(seedFromId('TRAPPIST-1 e')).not.toBe(seedFromId('TRAPPIST-1 f'));
});
it('stays a non-negative 32-bit integer', () => {
for (const id of ['', 'a', 'Kepler-186 f', 'HD 209458 b']) {
const seed = seedFromId(id);
expect(Number.isInteger(seed)).toBe(true);
expect(seed).toBeGreaterThanOrEqual(0);
expect(seed).toBeLessThan(2 ** 32);
}
});
});
describe('planetAppearance', () => {
it('derives the whole chain from published measurements', () => {
const earth = planetAppearance({ id: 'earth', radiusEarth: 1, massEarth: 1, semiMajorAxisAu: 1, hostLuminositySolar: 1 });
expect(earth.planetClass).toBe('temperate');
expect(earth.equilibriumTemperatureK).toBeCloseTo(255, -0.5);
expect(earth.bulkDensityGramsPerCm3).toBeCloseTo(EARTH_DENSITY_G_PER_CM3, 6);
expect(earth.polarCapExtentDeg).toBeGreaterThan(0);
expect(earth.palette.structure).toBe('terrain');
});
it('reports what it could not derive as null rather than guessing it', () => {
const unknown = planetAppearance({ id: 'x', radiusEarth: 1 });
expect(unknown.equilibriumTemperatureK).toBeNull();
expect(unknown.bulkDensityGramsPerCm3).toBeNull();
expect(unknown.polarCapExtentDeg).toBeNull();
expect(unknown.planetClass).toBe('rocky');
});
});
+339
View File
@@ -0,0 +1,339 @@
/**
* What a world probably looks like, derived from what has been measured about it.
*
* No exoplanet's surface has ever been imaged, and a handful of the solar system's own moons
* have no usable photograph in this app's asset set either. Rather than paint those bodies a
* flat colour chosen by category, this module reasons from the numbers that *are* published —
* radius, mass, orbital distance, and the host star's luminosity — to a temperature, a bulk
* density, and from those to a class of world with a palette and a surface structure.
*
* The chain is: mass and radius give density, which separates rock from ice from gas; the star's
* luminosity and the orbital distance give an equilibrium temperature, which decides whether
* that material is molten, solid, or frozen. Both steps are standard, and both are stated on
* screen — this produces a *derived* appearance, never a claim about an observation.
*/
/** Equilibrium temperature of a body at 1 AU from the Sun with zero albedo, in kelvin. */
export const SOLAR_EQUILIBRIUM_TEMPERATURE_K = 278.6;
/**
* Default Bond albedo where none is published, which is all of them. The solar system's rocky
* bodies cluster near this: Earth 0.31, Mars 0.25, Mercury 0.09, the Moon 0.11, and the giants
* 0.29-0.5. Anything in that range moves the temperature by a few per cent, since it enters as
* a fourth root.
*/
export const DEFAULT_BOND_ALBEDO = 0.3;
export const EARTH_RADIUS_KM = 6371;
/** Earth's mean bulk density, in g/cm3 — the reference every other world is compared against. */
export const EARTH_DENSITY_G_PER_CM3 = 5.51;
/**
* Class boundaries in Earth radii.
*
* Below the rocky ceiling a world cannot hold onto hydrogen. Above the gas-giant floor it is
* mostly hydrogen and helium. Between sit the ice giants — Uranus and Neptune are both close to
* 3.9 Earth radii — and below those the sub-Neptunes, the commonest kind of planet found and the
* one with no solar-system example at all.
*/
const ROCKY_MAX_RADIUS_EARTH = 1.8;
const ICE_GIANT_MIN_RADIUS_EARTH = 3.5;
const GAS_GIANT_MIN_RADIUS_EARTH = 6;
/**
* The same ladder in Earth masses, for the ~1400 planets with a published mass and no radius —
* mostly radial-velocity detections, which measure mass and never see a transit.
*/
const SUB_NEPTUNE_MIN_MASS_EARTH = 2;
const ICE_GIANT_MIN_MASS_EARTH = 8;
const GAS_GIANT_MIN_MASS_EARTH = 50;
/**
* Temperature boundaries in kelvin.
*
* The temperate band is the conservative one: Earth's equilibrium temperature is 255 K and
* Mars's is 206 K, both well inside it, while Venus's 300 K sits outside — which is the right
* answer here, since equilibrium temperature deliberately ignores the greenhouse effect that
* takes Venus's actual surface to 737 K.
*/
const LAVA_MIN_K = 1200;
const SCORCHED_MIN_K = 400;
const TEMPERATE_MIN_K = 175;
const TEMPERATE_MAX_K = 290;
const HOT_GIANT_MIN_K = 900;
/**
* Smallest world that gets called temperate, in Earth radii — about 1900 km, near the size below
* which a body cannot hold an atmosphere at all. Without it, Phobos comes out "temperate" on the
* strength of Mars's orbital distance, which is true of its temperature and absurd of the 11 km
* airless rock itself.
*/
const TEMPERATE_MIN_RADIUS_EARTH = 0.3;
/** Density boundaries in g/cm3, either side of the rocky band. */
const IRON_MIN_DENSITY = 7.5;
const VOLATILE_MAX_DENSITY = 3;
/**
* Bulk density in g/cm3 from a mass in Earth masses and a radius in Earth radii.
*
* This is the single most informative derived quantity about a planet: it is the difference
* between a ball of iron, a ball of rock, a ball of water and a ball of hydrogen, and it comes
* straight out of two published numbers with no modelling in between.
*/
export function bulkDensityGramsPerCm3(massEarth: number | undefined, radiusEarth: number | undefined): number | null {
if (!massEarth || !radiusEarth || massEarth <= 0 || radiusEarth <= 0) {
return null;
}
return EARTH_DENSITY_G_PER_CM3 * (massEarth / Math.pow(radiusEarth, 3));
}
/**
* Equilibrium temperature in kelvin: the temperature at which a body re-radiates exactly the
* starlight it absorbs.
*
* `T = 278.6 K * (L/Lsun)^(1/4) * (a/AU)^(-1/2) * (1-A)^(1/4)`, the standard blackbody balance
* for a rapidly-rotating body. It ignores internal heat and any greenhouse effect, both of which
* 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.
*/
export function equilibriumTemperatureK(
luminositySolar: number | null | undefined,
semiMajorAxisAu: number | undefined,
bondAlbedo: number = DEFAULT_BOND_ALBEDO
): number | null {
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);
}
/**
* The kinds of world this app distinguishes. Chosen to be the classes that actually look
* different from each other, and that the available measurements can actually separate.
*/
export type PlanetClass = 'lava' | 'scorched' | 'iron' | 'rocky' | 'temperate' | 'icy' | 'subNeptune' | 'iceGiant' | 'gasGiant' | 'hotGasGiant';
export interface PlanetMeasurements {
radiusEarth?: number;
massEarth?: number;
/** Equilibrium temperature, if it could be derived; `null`/absent when the star is unknown. */
equilibriumTemperatureK?: number | null;
}
/**
* Sorts a world into a class from its measurements.
*
* Size decides the family and temperature decides the state within it, which is the order the
* evidence actually supports: a radius separates a gas giant from a rock far more reliably than
* any temperature can, and temperature then separates a molten rock from a frozen one.
*
* With no temperature — the case for a planet whose host star is not in the star catalogue —
* every world falls back to the temperate-agnostic member of its family rather than being
* guessed at.
*/
export function classifyPlanet(measurements: PlanetMeasurements): PlanetClass {
const { radiusEarth, massEarth } = measurements;
const temperature = measurements.equilibriumTemperatureK ?? null;
const density = bulkDensityGramsPerCm3(massEarth, radiusEarth);
const family = sizeFamily(radiusEarth, massEarth);
if (family === 'gasGiant' || family === 'iceGiant') {
// Temperature separates a hot giant from a cold one but not a gas giant from an ice giant:
// Jupiter's equilibrium temperature is 110 K and Neptune's is 47 K, both freezing. What
// actually distinguishes them is how much hydrogen they hold, which is what size measures.
return temperature !== null && temperature >= HOT_GIANT_MIN_K ? 'hotGasGiant' : family;
}
if (family === 'subNeptune') {
return 'subNeptune';
}
// Below the rocky ceiling. Density, where it is known, overrides temperature at both extremes:
// an iron-rich world reads metallic whatever its temperature, and one too light to be rock is
// an ice/water world even if it sits where rock would be solid.
if (density !== null && density >= IRON_MIN_DENSITY) {
return 'iron';
}
if (temperature !== null && temperature >= LAVA_MIN_K) {
return 'lava';
}
if (density !== null && density <= VOLATILE_MAX_DENSITY && (temperature === null || temperature < TEMPERATE_MAX_K)) {
return 'icy';
}
if (temperature === null) {
return 'rocky';
}
if (temperature >= SCORCHED_MIN_K) {
return 'scorched';
}
if (temperature < TEMPERATE_MIN_K) {
return 'icy';
}
const bigEnoughForAnAtmosphere = radiusEarth === undefined || radiusEarth >= TEMPERATE_MIN_RADIUS_EARTH;
return temperature <= TEMPERATE_MAX_K && bigEnoughForAnAtmosphere ? 'temperate' : 'rocky';
}
/**
* Which family a world's bulk puts it in, from a radius where one is published and from a mass
* where only that is. Radius is preferred: it is what the classes are actually defined by, and
* mass alone leaves a dense super-Earth and a puffy sub-Neptune indistinguishable.
*/
function sizeFamily(radiusEarth: number | undefined, massEarth: number | undefined): 'rocky' | 'subNeptune' | 'iceGiant' | 'gasGiant' {
if (radiusEarth !== undefined && radiusEarth > 0) {
if (radiusEarth >= GAS_GIANT_MIN_RADIUS_EARTH) {
return 'gasGiant';
}
if (radiusEarth >= ICE_GIANT_MIN_RADIUS_EARTH) {
return 'iceGiant';
}
return radiusEarth > ROCKY_MAX_RADIUS_EARTH ? 'subNeptune' : 'rocky';
}
const mass = massEarth ?? 0;
if (mass >= GAS_GIANT_MIN_MASS_EARTH) {
return 'gasGiant';
}
if (mass >= ICE_GIANT_MIN_MASS_EARTH) {
return 'iceGiant';
}
return mass >= SUB_NEPTUNE_MIN_MASS_EARTH ? 'subNeptune' : 'rocky';
}
/** Human-readable name for a class, for the info panel. */
export const PLANET_CLASS_LABELS: Readonly<Record<PlanetClass, string>> = {
lava: 'Molten rock',
scorched: 'Scorched rock',
iron: 'Iron-rich world',
rocky: 'Rocky world',
temperate: 'Temperate rock',
icy: 'Ice world',
subNeptune: 'Sub-Neptune',
iceGiant: 'Ice giant',
gasGiant: 'Gas giant',
hotGasGiant: 'Hot gas giant'
};
/** An RGB triple in 0-1, the form the texture generator and Three.js both want. */
export type Rgb = readonly [number, number, number];
/**
* The palette and surface structure each class is drawn with.
*
* `low`/`mid`/`high` are the three tones the surface is built from — basin floor, general
* surface, highland or cloud top — and `cap` is the polar tone. Colours are reasoned from the
* chemistry each class implies: silicates and basalt are grey-brown, hot silicate cloud decks
* glow red, ammonia clouds are cream and ochre, and methane absorbs red light, which is exactly
* why Uranus and Neptune are the colour they are.
*/
export interface PlanetPalette {
readonly low: Rgb;
readonly mid: Rgb;
readonly high: Rgb;
readonly cap: Rgb;
/** `banded` for a fluid envelope with zonal flow, `terrain` for a solid surface. */
readonly structure: 'banded' | 'terrain';
/** How much the three tones separate, 0-1. Hazy worlds are flat, airless ones are stark. */
readonly contrast: number;
}
const PALETTES: Readonly<Record<PlanetClass, PlanetPalette>> = {
// Basalt darkened almost to black, cut by exposed magma. Real molten silicate at 1500 K glows
// a dull orange-red, not the yellow-white of a much hotter furnace.
lava: { low: [0.09, 0.06, 0.06], mid: [0.24, 0.13, 0.1], high: [0.95, 0.35, 0.12], cap: [0.32, 0.12, 0.08], structure: 'terrain', contrast: 0.95 },
// Baked rock with the volatiles long gone: Mercury's colour, which is what is left behind.
scorched: { low: [0.24, 0.2, 0.18], mid: [0.42, 0.36, 0.31], high: [0.6, 0.53, 0.46], cap: [0.45, 0.4, 0.36], structure: 'terrain', contrast: 0.8 },
// A world dense enough to be mostly metal reads darker and greyer than silicate rock.
iron: { low: [0.16, 0.15, 0.16], mid: [0.33, 0.31, 0.32], high: [0.52, 0.5, 0.53], cap: [0.4, 0.39, 0.41], structure: 'terrain', contrast: 0.7 },
rocky: { low: [0.25, 0.21, 0.18], mid: [0.45, 0.38, 0.31], high: [0.66, 0.58, 0.48], cap: [0.78, 0.78, 0.8], structure: 'terrain', contrast: 0.7 },
// Where water can be liquid. Deliberately restrained: this is a temperature, not a detection.
temperate: { low: [0.12, 0.2, 0.32], mid: [0.3, 0.36, 0.36], high: [0.55, 0.52, 0.42], cap: [0.9, 0.93, 0.96], structure: 'terrain', contrast: 0.6 },
icy: { low: [0.55, 0.62, 0.7], mid: [0.75, 0.81, 0.86], high: [0.92, 0.95, 0.98], cap: [0.97, 0.98, 1.0], structure: 'terrain', contrast: 0.45 },
// The commonest planet found and the one with no solar-system example. A thick hydrogen haze
// over an unseen interior, so: banded, but with almost no contrast to band.
subNeptune: { low: [0.42, 0.47, 0.5], mid: [0.58, 0.63, 0.64], high: [0.72, 0.76, 0.75], cap: [0.62, 0.67, 0.68], structure: 'banded', contrast: 0.22 },
// Methane absorbs red light; what comes back out is the blue-green of Uranus and Neptune.
iceGiant: { low: [0.13, 0.32, 0.55], mid: [0.24, 0.5, 0.72], high: [0.55, 0.78, 0.88], cap: [0.35, 0.6, 0.78], structure: 'banded', contrast: 0.45 },
// Ammonia cloud tops over ochre organics: the Jupiter/Saturn palette.
gasGiant: { low: [0.45, 0.32, 0.22], mid: [0.72, 0.6, 0.44], high: [0.92, 0.87, 0.76], cap: [0.6, 0.52, 0.42], structure: 'banded', contrast: 0.7 },
// Too hot for ammonia or water clouds; silicate and alkali-metal cloud decks over a glowing
// interior, which is why hot Jupiters are modelled as deep red rather than as bright ones.
hotGasGiant: { low: [0.28, 0.08, 0.07], mid: [0.55, 0.18, 0.12], high: [0.85, 0.42, 0.2], cap: [0.4, 0.14, 0.1], structure: 'banded', contrast: 0.6 }
};
export function paletteFor(planetClass: PlanetClass): PlanetPalette {
return PALETTES[planetClass];
}
/**
* Latitude, in degrees from the pole, that polar ice reaches down to — or `null` for a world
* where ice is not the question.
*
* Genuinely physical, and the clearest visible consequence of the derived temperature: caps
* grow as a world cools. They are absent above the point where water cannot be stable anywhere
* and cover the whole globe below the point where it cannot melt anywhere.
*/
export function polarCapExtentDeg(planetClass: PlanetClass, temperatureK: number | null | undefined): number | null {
if (temperatureK === null || temperatureK === undefined) {
return null;
}
if (planetClass !== 'temperate' && planetClass !== 'rocky' && planetClass !== 'icy') {
return null;
}
if (temperatureK >= TEMPERATE_MAX_K) {
return null;
}
if (temperatureK <= TEMPERATE_MIN_K) {
return 90;
}
// Linear between the two: nothing at the warm end, global at the cold end.
return 90 * ((TEMPERATE_MAX_K - temperatureK) / (TEMPERATE_MAX_K - TEMPERATE_MIN_K));
}
/** Everything the texture generator needs, and everything the info panel reports. */
export interface PlanetAppearance {
readonly planetClass: PlanetClass;
readonly palette: PlanetPalette;
readonly equilibriumTemperatureK: number | null;
readonly bulkDensityGramsPerCm3: number | null;
readonly polarCapExtentDeg: number | null;
/** Stable per body, so a world looks the same on every visit. */
readonly seed: number;
}
/** Stable 32-bit hash of a body id, so the same world is generated identically every time. */
export function seedFromId(id: string): number {
let hash = 2166136261;
for (let index = 0; index < id.length; index++) {
hash ^= id.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
}
/**
* The full derivation, from published measurements to a drawable appearance.
*
* `hostLuminositySolar` is the star's total output in solar units — see `stellar.ts`, which
* derives it from the star catalogue's own magnitude and distance. Without it there is no
* temperature, and the classification falls back to what size and density alone can say.
*/
export function planetAppearance(input: {
id: string;
radiusEarth?: number;
massEarth?: number;
semiMajorAxisAu?: number;
hostLuminositySolar?: number | null;
}): PlanetAppearance {
const temperature = equilibriumTemperatureK(input.hostLuminositySolar, input.semiMajorAxisAu);
const planetClass = classifyPlanet({ radiusEarth: input.radiusEarth, massEarth: input.massEarth, equilibriumTemperatureK: temperature });
return {
planetClass,
palette: paletteFor(planetClass),
equilibriumTemperatureK: temperature,
bulkDensityGramsPerCm3: bulkDensityGramsPerCm3(input.massEarth, input.radiusEarth),
polarCapExtentDeg: polarCapExtentDeg(planetClass, temperature),
seed: seedFromId(input.id)
};
}
+108
View File
@@ -0,0 +1,108 @@
import { describe, expect, it } from 'vitest';
import { absoluteMagnitude, bolometricCorrection, luminositySolar, SOLAR_ABSOLUTE_MAGNITUDE_V, SOLAR_BOLOMETRIC_MAGNITUDE } from './stellar';
/** Real catalogue rows, with the published luminosity each one should reproduce. */
const SIRIUS = { magnitude: -1.44, distancePc: 2.6371, spectralType: 'A0m...', publishedLuminosity: 25.4 };
const VEGA = { magnitude: 0.03, distancePc: 7.68, spectralType: 'A0Vvar', publishedLuminosity: 40 };
const PROXIMA = { magnitude: 11.01, distancePc: 1.2959, spectralType: 'M5Ve', publishedLuminosity: 0.0015 };
const ALPHA_CEN_A = { magnitude: -0.01, distancePc: 1.3247, spectralType: 'G2V', publishedLuminosity: 1.52 };
describe('absoluteMagnitude', () => {
it('is the apparent magnitude at the reference distance of ten parsecs', () => {
expect(absoluteMagnitude(5, 10)).toBeCloseTo(5, 12);
});
it('brightens a star as it is placed further away for the same apparent magnitude', () => {
expect(absoluteMagnitude(5, 100)).toBeLessThan(absoluteMagnitude(5, 10)!);
});
it('reproduces the published absolute magnitude of Sirius', () => {
expect(absoluteMagnitude(SIRIUS.magnitude, SIRIUS.distancePc)).toBeCloseTo(1.45, 1);
});
it('has no answer at zero distance, which in this catalogue is the Sun', () => {
expect(absoluteMagnitude(-26.7, 0)).toBeNull();
expect(absoluteMagnitude(5, -3)).toBeNull();
expect(absoluteMagnitude(Number.NaN, 10)).toBeNull();
});
});
describe('bolometricCorrection', () => {
it('is never positive: a star always radiates outside the V band as well as in it', () => {
for (const type of ['O5V', 'B2V', 'A0V', 'F5V', 'G2V', 'K5V', 'M5V', 'M9V', 'Unknown', '']) {
expect(bolometricCorrection(type)).toBeLessThanOrEqual(0);
}
});
it('is small for the Sun and large for a red dwarf, which is the whole reason it is applied', () => {
// An M dwarf emits most of its light in the infrared: taking its V magnitude at face value
// understates it by more than a factor of ten.
expect(Math.abs(bolometricCorrection('G2V'))).toBeLessThan(0.2);
expect(bolometricCorrection('M5V')).toBeLessThan(-2);
});
it('reproduces the Sun own correction closely enough to close the loop on the zero point', () => {
// The two solar magnitudes differ by exactly this correction, so a solar twin must come out
// at one solar luminosity.
expect(SOLAR_ABSOLUTE_MAGNITUDE_V + bolometricCorrection('G2V')).toBeCloseTo(SOLAR_BOLOMETRIC_MAGNITUDE, 1);
});
it('deepens monotonically from F through M, following the shift into the infrared', () => {
const sequence = ['F0V', 'G0V', 'K0V', 'M0V', 'M5V'].map((type) => bolometricCorrection(type));
for (let index = 1; index < sequence.length; index++) {
expect(sequence[index]).toBeLessThan(sequence[index - 1]);
}
});
it('falls back to a solar correction for an unclassified star rather than inventing one', () => {
expect(bolometricCorrection('Unknown')).toBeCloseTo(bolometricCorrection('G0V'), 6);
expect(bolometricCorrection(undefined)).toBeCloseTo(bolometricCorrection('G0V'), 6);
});
});
describe('luminositySolar', () => {
it('returns exactly one for the Sun, which defines the unit', () => {
expect(luminositySolar({ magnitude: -26.7, distancePc: 0, spectralType: 'G2V' })).toBe(1);
});
it('lands within a factor of two of the published luminosity for real stars', () => {
// The documented tolerance. It is looser than it sounds: equilibrium temperature goes as the
// fourth root of this, so a factor of two is under a fifth in temperature.
for (const star of [SIRIUS, VEGA, PROXIMA, ALPHA_CEN_A]) {
const derived = luminositySolar(star)!;
const ratio = derived / star.publishedLuminosity;
expect(ratio).toBeGreaterThan(0.5);
expect(ratio).toBeLessThan(2);
}
});
it('gets a solar analogue essentially exactly right', () => {
// Alpha Centauri A is the nearest star to a second Sun there is, so this is the case where
// an error would be a mistake rather than a tolerance.
expect(luminositySolar(ALPHA_CEN_A)!).toBeCloseTo(ALPHA_CEN_A.publishedLuminosity, 0);
});
it('orders stars the way their published luminosities do', () => {
const derived = [PROXIMA, ALPHA_CEN_A, SIRIUS, VEGA].map((star) => luminositySolar(star)!);
for (let index = 1; index < derived.length; index++) {
expect(derived[index]).toBeGreaterThan(derived[index - 1]);
}
});
it('applies the bolometric correction rather than taking V at face value', () => {
// Without it a red dwarf comes out more than ten times too dim.
const uncorrected = Math.pow(10, (SOLAR_BOLOMETRIC_MAGNITUDE - absoluteMagnitude(PROXIMA.magnitude, PROXIMA.distancePc)!) / 2.5);
expect(luminositySolar(PROXIMA)!).toBeGreaterThan(uncorrected * 5);
});
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);
expect(absurd).toBeLessThanOrEqual(1e7);
});
it('has no answer for a star with no usable distance', () => {
expect(luminositySolar({ magnitude: 5, distancePc: -1 })).toBeNull();
});
});
+124
View File
@@ -0,0 +1,124 @@
import { parseSpectralClass, SpectralClass } from './spectral';
/**
* Stellar luminosity, derived from the two things the star catalogue actually measures.
*
* Nothing here is a published luminosity: HYG carries apparent magnitude and a parallax, and
* the Exoplanet Archive columns that would give a host star's mass or effective temperature are
* not in the shipped dataset. What those two measurements do give, exactly, is absolute
* magnitude — and from there the bolometric correction below turns a V-band brightness into a
* total energy output, which is what a planet's temperature actually depends on.
*/
/** The Sun's absolute magnitude in V — what the distance modulus below is measured against. */
export const SOLAR_ABSOLUTE_MAGNITUDE_V = 4.83;
/**
* The Sun's absolute *bolometric* magnitude, the IAU 2015 zero point. Distinct from the V-band
* figure above by the Sun's own bolometric correction, and it is the one the ratio is taken
* against — mixing the two would leave every luminosity 9% high.
*/
export const SOLAR_BOLOMETRIC_MAGNITUDE = 4.74;
/**
* Bolometric corrections for main-sequence stars, at subclass 0 of each class (Pecaut & Mamajek
* 2013, rounded). Always negative: a star radiates outside the V band as well as in it, so its
* total output always exceeds what a visual magnitude alone implies.
*
* The correction matters most exactly where it is largest. An M dwarf emits the bulk of its
* light in the infrared, so taking its V magnitude at face value understates it by more than a
* factor of ten — and M dwarfs are what most of the nearby planet hosts are.
*/
const BOLOMETRIC_CORRECTION_ANCHORS: Readonly<Record<SpectralClass, number>> = {
O: -4.0,
B: -3.0,
A: -0.25,
F: -0.01,
G: -0.06,
K: -0.24,
M: -1.21
};
/** Correction at the cool end of class M, so the latest subclasses interpolate toward it. */
const BEYOND_M_CORRECTION = -4.6;
/**
* Range the derived luminosity is clamped to, in solar luminosities.
*
* A guard against the one systematic error this method cannot detect on its own: the
* corrections above assume a main-sequence star, and HYG often records a spectral class with no
* luminosity class at all. A red giant read as a K dwarf comes out hundreds of times too
* bright, which is a large error but not an unbounded one — these bounds simply keep a
* pathological record from producing a temperature of a million kelvin.
*/
const MIN_LUMINOSITY_SOLAR = 1e-6;
const MAX_LUMINOSITY_SOLAR = 1e7;
/**
* Absolute magnitude from apparent magnitude and distance — the distance modulus.
*
* Returns `null` for a star at zero distance, which in this catalogue means the Sun: its
* apparent magnitude of -26.7 is a statement about how close it is, not about how bright it is,
* and the formula has no answer there.
*/
export function absoluteMagnitude(apparentMagnitude: number, distancePc: number): number | null {
if (!Number.isFinite(apparentMagnitude) || !Number.isFinite(distancePc) || distancePc <= 0) {
return null;
}
return apparentMagnitude - 5 * Math.log10(distancePc) + 5;
}
/**
* Bolometric correction for a spectral type, interpolated between the class anchors. Falls back
* to the solar value when the catalogue records no usable classification, which biases a
* misclassified red dwarf dim rather than inventing a correction for it.
*/
export function bolometricCorrection(spectralType: string | null | undefined): number {
const parsed = parseSpectralClass(spectralType);
if (!parsed) {
return BOLOMETRIC_CORRECTION_ANCHORS.G;
}
const { spectralClass, subclass } = parsed;
const classes = Object.keys(BOLOMETRIC_CORRECTION_ANCHORS) as SpectralClass[];
const index = classes.indexOf(spectralClass);
const from = BOLOMETRIC_CORRECTION_ANCHORS[spectralClass];
const to = index < classes.length - 1 ? BOLOMETRIC_CORRECTION_ANCHORS[classes[index + 1]] : BEYOND_M_CORRECTION;
const t = Math.min(Math.max(subclass, 0), 10) / 10;
return from + (to - from) * t;
}
/** Everything about a star that bears on how much light it puts out. */
export interface StellarPhotometry {
/** Apparent visual magnitude, as catalogued. */
magnitude: number;
/** Distance from the Sun in parsecs; `0` identifies the Sun itself. */
distancePc: number;
spectralType?: string;
}
/**
* Total luminosity in solar units.
*
* The Sun is returned as exactly 1 rather than derived — it is the definition of the unit, and
* it is the one star whose distance in this catalogue is zero.
*
* Accurate to roughly a factor of two for main-sequence stars, which is better than it sounds
* for what it is used for: a planet's equilibrium temperature goes as the fourth root of this,
* so even a factor of two moves a temperature by less than a fifth.
*/
export function luminositySolar(star: StellarPhotometry): number | null {
if (star.distancePc === 0) {
return 1;
}
const absolute = absoluteMagnitude(star.magnitude, star.distancePc);
if (absolute === null) {
return null;
}
const bolometric = absolute + bolometricCorrection(star.spectralType);
const luminosity = Math.pow(10, (SOLAR_BOLOMETRIC_MAGNITUDE - bolometric) / 2.5);
return Math.min(Math.max(luminosity, MIN_LUMINOSITY_SOLAR), MAX_LUMINOSITY_SOLAR);
}