diff --git a/src/conjunction.ts b/src/conjunction.ts index 0eb89ca..c2d27a0 100644 --- a/src/conjunction.ts +++ b/src/conjunction.ts @@ -17,12 +17,16 @@ import type { import { convertEclipticToEquatorial, convertEquatorialToHorizontal } from './coordinates' +import { getLunarEquatorialCoordinate } from './moon' + import { type Planet, getPlanetaryGeocentricEclipticCoordinate, getPlanetaryPositions } from './planets' +import { getCorrectionToEquatorialForPrecessionOfEquinoxes } from './precession' + import { convertRadiansToDegrees as degrees, getNormalizedAzimuthalDegree, @@ -445,3 +449,136 @@ export const findPlanetaryConjunctions = ( } /*****************************************************************************************************************/ + +// The equatorial coordinate, of the standard epoch, of Spica, α Virginis, which lies ~2° south of +// the ecliptic, and which the Moon and the planets therefore pass close to: +const SPICA: EquatorialCoordinate = { ra: 201.298, dec: -11.1613 } + +/*****************************************************************************************************************/ + +// The equatorial coordinate, of the standard epoch, of Regulus, α Leonis, which lies ~0.5° north of +// the ecliptic, and which the Moon and the planets therefore pass close to: +const REGULUS: EquatorialCoordinate = { ra: 152.093, dec: 11.9672 } + +/*****************************************************************************************************************/ + +/** + * findConjunctions + * + * Finds all conjunctions of the planets, the Moon, Spica and Regulus within a given time interval, + * returning only those that are in conjunction with each other (as determined by the angular + * separation threshold). + * + * @param interval - The interval to search for the initial conjunction. + * @param observer - The geographic coordinate of the observer. + * @param horizon - The minimum altitude of the targets above the horizon. + * @param angularSeparationThreshold - The minimum angular separation for conjunction. + * @param stepMinutes - The step size in minutes for checking conjunction. + * @throws An error if the step size is not finite, or is not greater than zero. + * @returns The closest conjunction found for each pair of targets, keyed by their names. + * + */ +export const findConjunctions = ( + interval: Interval, + observer: GeographicCoordinate, + params: { + horizon?: number // six degrees above the horizon + angularSeparationThreshold?: number // three degrees of separation + stepMinutes?: number // check every 1/3 hour + } = { + horizon: 6, + angularSeparationThreshold: ANGULAR_SEPARATION_THRESHOLD, + stepMinutes: 20 + } +): Map => { + // A conjunction is a close apparent approach of two celestial objects in the sky. + const conjunctions = new Map() + + // The start of the interval is carried forward by the step as it is traversed, while the end of + // it is not, and so the two are taken separately: + let from = interval.from + + const to = interval.to + + const { + horizon = 6, + angularSeparationThreshold = ANGULAR_SEPARATION_THRESHOLD, + stepMinutes = 20 + } = params + + // A step of zero (or less) would never advance through the interval, and would therefore + // search for a conjunction indefinitely: + if (!Number.isFinite(stepMinutes) || stepMinutes <= 0) { + throw new Error('Invalid step: stepMinutes must be finite and greater than zero') + } + + while (from <= to) { + const moon = getLunarEquatorialCoordinate(from) + + // The coordinates of the stars are of the standard epoch, and so they are precessed to the + // epoch of the observation: the equinox carries them by ~22 arcminutes over the quarter + // century from J2000, which is an appreciable fraction of the separation a conjunction is + // resolved at: + const stars = ( + [ + ['Spica', SPICA], + ['Regulus', REGULUS] + ] as const + ).map(([name, star]) => { + const precession = getCorrectionToEquatorialForPrecessionOfEquinoxes(from, star) + + const target = { ra: star.ra + precession.ra, dec: star.dec + precession.dec } + + return { name, ...target, ...convertEquatorialToHorizontal(from, observer, target) } + }) + + // Collate the positions of all planets other than Earth, of the Moon, and of the stars. They + // may be in conjunction, but they won't be visible to our local observer if they are below + // the horizon, which isConjunction() rejects below: + const positions: Target[] = [ + ...getPlanetaryPositions(from, observer), + { + name: 'Moon', + ...moon, + ...convertEquatorialToHorizontal(from, observer, moon) + }, + ...stars + ] + + // Loop over all pairs of targets and check for conjunctions: + for (let i = 0; i < positions.length; i++) { + for (let j = i + 1; j < positions.length; j++) { + const alterior = positions[i] + + const ulterior = positions[j] + + // Create a unique key for the conjunction between the two targets, sorted by name: + const key = [alterior.name, ulterior.name].sort().join('-') + + const conjunction = isConjunction(from, [alterior, ulterior], { + horizon, + angularSeparationThreshold + }) + + // The closest approach of the pair resolved so far, if the pair has been in conjunction + // at an earlier step of the interval: + const closest = conjunctions.get(key) + + // Update the conjunction where it is the closest approach of the pair found so far: + if ( + conjunction && + (!closest || closest.angularSeparation > conjunction.angularSeparation) + ) { + conjunctions.set(key, conjunction) + } + } + } + + // Increment the from date by the step size: + from = new Date(from.getTime() + stepMinutes * 60000) + } + + return conjunctions +} + +/*****************************************************************************************************************/ diff --git a/tests/conjunction.spec.ts b/tests/conjunction.spec.ts index e63d943..2afbf9c 100644 --- a/tests/conjunction.spec.ts +++ b/tests/conjunction.spec.ts @@ -15,9 +15,11 @@ import { convertEclipticToEquatorial, convertEquatorialToHorizontal, findConjunction, + findConjunctions, findPlanetaryConjunction, findPlanetaryConjunctions, getLunarEquatorialCoordinate, + getCorrectionToEquatorialForPrecessionOfEquinoxes, getMidpointEquatorialCoordinate, getPlanetaryGeocentricEclipticCoordinate, isConjunction, @@ -433,3 +435,140 @@ describe('findPlanetaryConjunctions()', () => { }) /*****************************************************************************************************************/ + +describe('findConjunctions()', () => { + it('should be defined', () => { + expect(findConjunctions).toBeDefined() + }) + + it('should find the conjunctions of the planets, the Moon and the stars over an interval', () => { + const datetime = new Date('2023-01-01T10:00:00Z') + + const conjunctions = findConjunctions( + { + from: datetime, + to: new Date(datetime.getTime() + 1000 * 60 * 60 * 24 * 7) + }, + { latitude, longitude } + ) + + expect(conjunctions).toBeDefined() + expect(conjunctions.size).toBeGreaterThan(0) + + for (const [key, conjunction] of conjunctions) { + // The key is the names of the two targets, sorted, and so it identifies the pair: + expect(key).toBe([conjunction.targets[0].name, conjunction.targets[1].name].sort().join('-')) + + expect(conjunction.angularSeparation).toBeLessThanOrEqual(3) + + expect(Number.isFinite(conjunction.ra)).toBe(true) + expect(Number.isFinite(conjunction.dec)).toBe(true) + } + }) + + it('should resolve the Moon and the stars as well as the planets', () => { + // The Moon travels the whole ecliptic in a month, and so over a month it passes close to one + // of the planets, or to Spica or to Regulus, e.g., the targets beyond the planets are + // resolved and are not omitted from the search: + const datetime = new Date('2023-01-01T10:00:00Z') + + const conjunctions = findConjunctions( + { + from: datetime, + to: new Date(datetime.getTime() + 1000 * 60 * 60 * 24 * 30) + }, + { latitude, longitude } + ) + + const names = new Set( + Array.from(conjunctions.values()).flatMap(({ targets }) => targets.map(({ name }) => name)) + ) + + expect(names.has('Moon') || names.has('Spica') || names.has('Regulus')).toBe(true) + }) + + it('should resolve the midpoint of a pair that straddles the zero of right ascension', () => { + // The midpoint is taken on the celestial sphere, and not as the mean of the two right + // ascensions, which places a pair either side of 0h at the opposite side of the sky: + const datetime = new Date('2023-01-01T10:00:00Z') + + const conjunctions = findConjunctions( + { + from: datetime, + to: new Date(datetime.getTime() + 1000 * 60 * 60 * 24 * 30) + }, + { latitude, longitude } + ) + + for (const conjunction of conjunctions.values()) { + const [hither, tither] = conjunction.targets + + const { ra, dec } = getMidpointEquatorialCoordinate(hither, tither) + + expect(conjunction.ra).toBeCloseTo(ra, 9) + expect(conjunction.dec).toBeCloseTo(dec, 9) + } + }) + + it('should precess the stars to the epoch of the observation', () => { + // Mercury passes within a quarter of a degree of Regulus on the 28th of July 2023. The + // coordinates of Regulus are of the standard epoch, and the equinox carries it ~19 arcminutes + // over the interval to 2023, which is an appreciable fraction of the 3° the conjunction is + // resolved at, and so the star is precessed to the epoch of the observation: + const datetime = new Date('2023-07-28T12:00:00Z') + + const conjunctions = findConjunctions( + { + from: datetime, + to: new Date(datetime.getTime() + 1000 * 60 * 60 * 12) + }, + { latitude, longitude } + ) + + const conjunction = conjunctions.get('Mercury-Regulus') + + expect(conjunction).toBeDefined() + + if (!conjunction) { + throw new Error('Conjunction between Mercury & Regulus is not defined') + } + + const regulus = conjunction.targets.find(({ name }) => name === 'Regulus') + + expect(regulus).toBeDefined() + + if (!regulus) { + throw new Error('Regulus is not among the targets of the conjunction') + } + + // The coordinate of Regulus at the standard epoch, e.g., as it is given to the search: + const J2000 = { ra: 152.093, dec: 11.9672 } + + const precession = getCorrectionToEquatorialForPrecessionOfEquinoxes( + conjunction.datetime, + J2000 + ) + + expect(regulus.ra).toBeCloseTo(J2000.ra + precession.ra, 9) + expect(regulus.dec).toBeCloseTo(J2000.dec + precession.dec, 9) + + // The precession is not negligible against the threshold the conjunction is resolved at: + expect(Math.abs(precession.ra)).toBeGreaterThan(0.1) + }) + + it('should throw for a step size that would never advance through the interval', () => { + const datetime = new Date('2023-01-01T10:00:00Z') + + for (const stepMinutes of [0, -20, Number.NaN, Number.POSITIVE_INFINITY]) { + expect(() => + findConjunctions( + { from: datetime, to: new Date(datetime.getTime() + 1000 * 60 * 60 * 24) }, + { latitude, longitude }, + { stepMinutes } + ) + ).toThrow('Invalid step: stepMinutes must be finite and greater than zero') + } + }) +}) + +/*****************************************************************************************************************/