src/physics/seasons.js
// ————————————————————————————————————————————————
// Solar declination and seasons (#82).
//
// Pure functions — solar declination as a closed-form expression of
// Earth's heliocentric longitude, season name from the four cardinal
// solar events bracketing fromJD.
// ————————————————————————————————————————————————
import { planetById } from '../data/planets.js';
import { helioLongitude, nextSolarEvent } from './events.js';
// Sun's geocentric ecliptic longitude (rad), wrapped to [0, 2π).
// (Local copy — events.js doesn't export this helper.)
function sunGeocentricLongitude(jd) {
const earth = planetById('earth');
let lon = helioLongitude(earth, jd) + Math.PI;
lon = ((lon % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI);
return lon;
}
// Earth's obliquity in radians. Read once at module load — the JPL fit
// holds this constant within the simulation's accuracy budget.
const EARTH_TILT_RAD = (() => {
const earth = planetById('earth');
return earth.axialTiltRad;
})();
/**
* Solar declination δ at JD: the latitude on Earth where the Sun is
* directly overhead at local noon.
*
* δ = arcsin(sin(ε) · sin(L_sun))
*
* where ε is Earth's obliquity (~23.44°) and L_sun is the Sun's
* geocentric ecliptic longitude.
*
* Returns radians in [-ε, +ε]. Accuracy: better than ~0.01° for any JD
* inside the J2000-fit window (1800–2050), since this rides on the same
* JPL pipeline as the rest of the simulation.
*/
export function solarDeclinationAtJD(jd) {
const lonSun = sunGeocentricLongitude(jd);
return Math.asin(Math.sin(EARTH_TILT_RAD) * Math.sin(lonSun));
}
const SEASON_FROM_KIND = {
marchEquinox: 'spring',
juneSolstice: 'summer',
septemberEquinox: 'autumn',
decemberSolstice: 'winter',
};
/**
* Northern-hemisphere season at JD. Returns 'spring' | 'summer' |
* 'autumn' | 'winter'.
*
* Implementation: the season at any JD is the one that *started* at
* the most recent cardinal event. We scan backward for each of the
* four kinds and take the one whose JD is largest (≤ fromJD).
*/
export function seasonAtJD(jd) {
let bestJD = -Infinity;
let bestKind = null;
for (const kind of Object.keys(SEASON_FROM_KIND)) {
const r = nextSolarEvent(kind, jd, 400, { direction: -1 });
if (r && r.jd > bestJD) {
bestJD = r.jd;
bestKind = kind;
}
}
return bestKind ? SEASON_FROM_KIND[bestKind] : null;
}
/**
* JD of the next sunset at a geographic location, scanning forward from
* fromJD. Returns null if the location is in polar night or polar day
* (no sunset within the next two UTC days).
*
* Closed-form: hour angle H from solar noon satisfies
* cos(H) = -tan(φ) · tan(δ)
* where φ = latitude, δ = solar declination at local noon. The sunset
* UTC clock time is then 12 − λ/15 + H/15° hours, where λ = longitude
* (east positive).
*
* Approximation: ignores equation of time and atmospheric refraction.
* Accuracy ≈ ±15 minutes — sufficient for narrative framing where the
* goal is "the sun is on the horizon", not navigation.
*/
export function nextSunsetAtLocation({ latDeg, lonDeg }, fromJD) {
const latRad = (latDeg * Math.PI) / 180;
for (let dayOffset = 0; dayOffset < 2; dayOffset++) {
const midnightJD = Math.floor(fromJD - 0.5) + 0.5 + dayOffset;
const declRad = solarDeclinationAtJD(midnightJD + 0.5); // sample at local noon
const cosH = -Math.tan(latRad) * Math.tan(declRad);
if (cosH > 1 || cosH < -1) continue; // polar night or midnight sun
const HHours = (Math.acos(cosH) * 12) / Math.PI;
const sunsetUTCHours = 12 - lonDeg / 15 + HHours;
const sunsetJD = midnightJD + sunsetUTCHours / 24;
if (sunsetJD > fromJD) return sunsetJD;
}
return null;
}
← Astrarium