@@ -165,129 +165,214 @@ private readonly struct LatitudeTerm(int d, int m, int mp, int f, int sigmaB)
165165 new ( 4 , - 1 , 0 , - 1 , 115 ) ,
166166 new ( 2 , - 2 , 0 , 1 , 107 ) ,
167167 ] ;
168-
169- /// <summary>
170- /// Calculates the Moon's position in Earth-Centered Inertial (ECI) coordinates
171- /// using the truncated ELP 2000/82 lunar theory.
172- /// </summary>
173- /// <param name="time">The time of observation (UTC).</param>
174- /// <returns>
175- /// An EciCoordinate representing the Moon's position. The Position vector is in kilometers
176- /// relative to Earth's center. Velocity is zero (not computed by this algorithm).
177- /// </returns>
178- /// <remarks>
179- /// <para>
180- /// This implementation follows Jean Meeus, "Astronomical Algorithms", 2nd Edition,
181- /// Chapter 47 "Position of the Moon" (pp. 337–342). The algorithm is a truncated
182- /// version of the ELP 2000/82 lunar theory by Chapront-Touzé and Chapront.
183- /// </para>
184- /// <para>
185- /// Accuracy is approximately 10 arcseconds in latitude and 4 arcseconds in longitude
186- /// for dates between 1900 and 2100. This does not account for nutation or the
187- /// conversion from Terrestrial Time (TT) to Universal Time (UT1), which introduces
188- /// an additional error of ~30 arcseconds when using UTC directly.
189- /// </para>
190- /// <para>
191- /// The returned ECI coordinate can be converted to geodetic coordinates via
192- /// <c>eci.ToGeodetic()</c> to obtain the sublunar point, or used with
193- /// <c>GroundStation.Observe()</c> for moonrise/moonset and lunar track calculations.
194- /// </para>
195- /// </remarks>
196- public static EciCoordinate Predict ( DateTime time )
197- {
198- var jde = time . ToJulian ( ) ;
199- var t = ( jde - 2451545.0 ) / 36525.0 ;
200-
201- var degToRad = Math . PI / 180.0 ;
202-
203- var lPrimeDeg = Horner ( t , 218.3164477 , 481267.88123421 , - 0.0015786 , 1.0 / 538841.0 , - 1.0 / 65194000.0 ) ;
204- var dDeg = Horner ( t , 297.8501921 , 445267.1114034 , - 0.0018819 , 1.0 / 545868.0 , - 1.0 / 113065000.0 ) ;
205- var mDeg = Horner ( t , 357.5291092 , 35999.0502909 , - 0.0001535 , 1.0 / 24490000.0 ) ;
206- var mPrimeDeg = Horner ( t , 134.9633964 , 477198.8675055 , 0.0087414 , 1.0 / 69699.0 , - 1.0 / 14712000.0 ) ;
207- var fDeg = Horner ( t , 93.2720950 , 483202.0175233 , - 0.0036539 , - 1.0 / 3526000.0 , 1.0 / 863310000.0 ) ;
208-
209- var lPrime = MathUtil . Wrap360 ( lPrimeDeg ) * degToRad ;
210- var d = MathUtil . Wrap360 ( dDeg ) * degToRad ;
211- var m = MathUtil . Wrap360 ( mDeg ) * degToRad ;
212- var mPrime = MathUtil . Wrap360 ( mPrimeDeg ) * degToRad ;
213- var f = MathUtil . Wrap360 ( fDeg ) * degToRad ;
214-
215- var a1 = ( 119.75 + 131.849 * t ) * degToRad ;
216- var a2 = ( 53.09 + 479264.29 * t ) * degToRad ;
217- var a3 = ( 313.45 + 481266.484 * t ) * degToRad ;
218-
219- var e = 1.0 - 0.002516 * t - 0.0000074 * t * t ;
220- var e2 = e * e ;
221-
222- var sigmaL = 3958.0 * Math . Sin ( a1 )
223- + 1962.0 * Math . Sin ( lPrime - f )
224- + 318.0 * Math . Sin ( a2 ) ;
225- var sigmaR = 0.0 ;
226- var sigmaB = - 2235.0 * Math . Sin ( lPrime )
227- + 382.0 * Math . Sin ( a3 )
228- + 175.0 * Math . Sin ( a1 - f )
229- + 175.0 * Math . Sin ( a1 + f )
230- + 127.0 * Math . Sin ( lPrime - mPrime )
231- - 115.0 * Math . Sin ( lPrime + mPrime ) ;
232-
233- for ( var i = 0 ; i < LongitudeTerms . Length ; i ++ )
234- {
235- ref var term = ref LongitudeTerms [ i ] ;
236- var arg = d * term . D + m * term . M + mPrime * term . MP + f * term . F ;
237- var sa = Math . Sin ( arg ) ;
238- var ca = Math . Cos ( arg ) ;
239-
240- var factor = term . M switch
241- {
242- 0 => 1.0 ,
243- 1 or - 1 => e ,
244- 2 or - 2 => e2 ,
245- _ => 1.0
246- } ;
247-
248- sigmaL += term . SigmaL * sa * factor ;
249- sigmaR += term . SigmaR * ca * factor ;
250- }
251-
252- for ( var i = 0 ; i < LatitudeTerms . Length ; i ++ )
253- {
254- ref var term = ref LatitudeTerms [ i ] ;
255- var arg = d * term . D + m * term . M + mPrime * term . MP + f * term . F ;
256- var sb = Math . Sin ( arg ) ;
257-
258- var factor = term . M switch
259- {
260- 0 => 1.0 ,
261- 1 or - 1 => e ,
262- 2 or - 2 => e2 ,
263- _ => 1.0
264- } ;
265-
266- sigmaB += term . SigmaB * sb * factor ;
267- }
268-
269- var lambdaRad = WrapPi ( lPrime + sigmaL * 1e-6 * degToRad ) ;
270- var betaRad = sigmaB * 1e-6 * degToRad ;
271- var distanceKm = 385000.56 + sigmaR * 1e-3 ;
272-
273- var cosLambda = Math . Cos ( lambdaRad ) ;
274- var sinLambda = Math . Sin ( lambdaRad ) ;
275- var cosBeta = Math . Cos ( betaRad ) ;
276- var sinBeta = Math . Sin ( betaRad ) ;
277-
278- var epsilonDeg = 23.439 - 0.0000004 * ( jde - 2451545.0 ) ;
279- var epsilon = epsilonDeg * degToRad ;
280- var cosEpsilon = Math . Cos ( epsilon ) ;
281- var sinEpsilon = Math . Sin ( epsilon ) ;
282-
283- var xKm = distanceKm * cosBeta * cosLambda ;
284- var yKm = distanceKm * ( cosEpsilon * cosBeta * sinLambda - sinEpsilon * sinBeta ) ;
285- var zKm = distanceKm * ( sinEpsilon * cosBeta * sinLambda + cosEpsilon * sinBeta ) ;
286-
287- return new EciCoordinate ( time , new Vector3 ( xKm , yKm , zKm ) ) ;
288- }
289-
290- private static double Horner ( double t , params double [ ] coeffs )
168+
169+ /// <summary>
170+ /// Calculates the Moon's geocentric position using the truncated ELP 2000/82
171+ /// lunar theory as presented by Meeus, "Astronomical Algorithms" 2nd Ed., Chapter 47.
172+ /// </summary>
173+ /// <param name="time">The time of observation (UTC).</param>
174+ /// <returns>
175+ /// An EciCoordinate representing the Moon's position as a geocentric equatorial
176+ /// Cartesian vector in the mean equator and mean equinox of date, in kilometers.
177+ /// Velocity is zero (not computed by this algorithm).
178+ /// </returns>
179+ /// <remarks>
180+ /// <para>
181+ /// The periodic lunar series returns geocentric ecliptic longitude, latitude,
182+ /// and distance referred to the mean equinox/ecliptic of date. This method
183+ /// converts those to a geocentric equatorial Cartesian vector of date.
184+ /// </para>
185+ /// <para>
186+ /// This is not a J2000/GCRF/TEME state vector. Feeding this into observation
187+ /// code that assumes a different ECI frame may produce plausible-looking but
188+ /// incorrect results.
189+ /// </para>
190+ /// <para>
191+ /// Input UTC is converted to TT (Terrestrial Time) using the IERS leap second
192+ /// table before evaluating the lunar series. Using UTC directly would introduce
193+ /// a date-dependent error of tens of arcseconds for modern dates (~69s offset
194+ /// as of 2026, or ~40 arcseconds in lunar position).
195+ /// </para>
196+ /// <para>
197+ /// Nutation, topocentric parallax, light-time, and frame transformations to
198+ /// Earth-fixed coordinates are not included. Meeus quotes approximate accuracy
199+ /// of about 10 arcseconds in longitude and 4 arcseconds in latitude for the
200+ /// truncated lunar series.
201+ /// </para>
202+ /// </remarks>
203+ public static EciCoordinate Predict ( DateTime time )
204+ {
205+ // Convert UTC to TT for the ephemeris argument.
206+ // TT = UTC + (TAI - UTC) + 32.184s
207+ var jdUtc = time . ToJulian ( ) ;
208+ var jdTt = LeapSeconds . UtcToTt ( jdUtc ) ;
209+ var t = ( jdTt - 2451545.0 ) / 36525.0 ;
210+
211+ var degToRad = Math . PI / 180.0 ;
212+
213+ var lPrimeDeg = Horner ( t , 218.3164477 , 481267.88123421 , - 0.0015786 , 1.0 / 538841.0 , - 1.0 / 65194000.0 ) ;
214+ var dDeg = Horner ( t , 297.8501921 , 445267.1114034 , - 0.0018819 , 1.0 / 545868.0 , - 1.0 / 113065000.0 ) ;
215+ var mDeg = Horner ( t , 357.5291092 , 35999.0502909 , - 0.0001536 , 1.0 / 24490000.0 ) ;
216+ var mPrimeDeg = Horner ( t , 134.9633964 , 477198.8675055 , 0.0087414 , 1.0 / 69699.0 , - 1.0 / 14712000.0 ) ;
217+ var fDeg = Horner ( t , 93.2720950 , 483202.0175233 , - 0.0036539 , - 1.0 / 3526000.0 , 1.0 / 863310000.0 ) ;
218+
219+ var lPrime = MathUtil . Wrap360 ( lPrimeDeg ) * degToRad ;
220+ var d = MathUtil . Wrap360 ( dDeg ) * degToRad ;
221+ var m = MathUtil . Wrap360 ( mDeg ) * degToRad ;
222+ var mPrime = MathUtil . Wrap360 ( mPrimeDeg ) * degToRad ;
223+ var f = MathUtil . Wrap360 ( fDeg ) * degToRad ;
224+
225+ var a1 = ( 119.75 + 131.849 * t ) * degToRad ;
226+ var a2 = ( 53.09 + 479264.29 * t ) * degToRad ;
227+ var a3 = ( 313.45 + 481266.484 * t ) * degToRad ;
228+
229+ var e = 1.0 - 0.002516 * t - 0.0000074 * t * t ;
230+ var e2 = e * e ;
231+
232+ var sigmaL = 3958.0 * Math . Sin ( a1 )
233+ + 1962.0 * Math . Sin ( lPrime - f )
234+ + 318.0 * Math . Sin ( a2 ) ;
235+ var sigmaR = 0.0 ;
236+ var sigmaB = - 2235.0 * Math . Sin ( lPrime )
237+ + 382.0 * Math . Sin ( a3 )
238+ + 175.0 * Math . Sin ( a1 - f )
239+ + 175.0 * Math . Sin ( a1 + f )
240+ + 127.0 * Math . Sin ( lPrime - mPrime )
241+ - 115.0 * Math . Sin ( lPrime + mPrime ) ;
242+
243+ for ( var i = 0 ; i < LongitudeTerms . Length ; i ++ )
244+ {
245+ ref var term = ref LongitudeTerms [ i ] ;
246+ var arg = d * term . D + m * term . M + mPrime * term . MP + f * term . F ;
247+ var sa = Math . Sin ( arg ) ;
248+ var ca = Math . Cos ( arg ) ;
249+
250+ var factor = term . M switch
251+ {
252+ 0 => 1.0 ,
253+ 1 or - 1 => e ,
254+ 2 or - 2 => e2 ,
255+ _ => 1.0
256+ } ;
257+
258+ sigmaL += term . SigmaL * sa * factor ;
259+ sigmaR += term . SigmaR * ca * factor ;
260+ }
261+
262+ for ( var i = 0 ; i < LatitudeTerms . Length ; i ++ )
263+ {
264+ ref var term = ref LatitudeTerms [ i ] ;
265+ var arg = d * term . D + m * term . M + mPrime * term . MP + f * term . F ;
266+ var sb = Math . Sin ( arg ) ;
267+
268+ var factor = term . M switch
269+ {
270+ 0 => 1.0 ,
271+ 1 or - 1 => e ,
272+ 2 or - 2 => e2 ,
273+ _ => 1.0
274+ } ;
275+
276+ sigmaB += term . SigmaB * sb * factor ;
277+ }
278+
279+ // Geocentric ecliptic longitude, latitude, and distance (mean of date).
280+ var lambdaRad = WrapPi ( lPrime + sigmaL * 1e-6 * degToRad ) ;
281+ var betaRad = sigmaB * 1e-6 * degToRad ;
282+ var distanceKm = 385000.56 + sigmaR * 1e-3 ;
283+
284+ // Use Meeus mean obliquity polynomial (not the rough linear approximation).
285+ var epsilonRad = MathUtil . DegreesToRadians ( Obliquity . MeanObliquityDeg ( jdTt ) ) ;
286+ var cosEpsilon = Math . Cos ( epsilonRad ) ;
287+ var sinEpsilon = Math . Sin ( epsilonRad ) ;
288+
289+ var cosLambda = Math . Cos ( lambdaRad ) ;
290+ var sinLambda = Math . Sin ( lambdaRad ) ;
291+ var cosBeta = Math . Cos ( betaRad ) ;
292+ var sinBeta = Math . Sin ( betaRad ) ;
293+
294+ // Rotate from ecliptic to equatorial (mean of date).
295+ var xKm = distanceKm * cosBeta * cosLambda ;
296+ var yKm = distanceKm * ( cosEpsilon * cosBeta * sinLambda - sinEpsilon * sinBeta ) ;
297+ var zKm = distanceKm * ( sinEpsilon * cosBeta * sinLambda + cosEpsilon * sinBeta ) ;
298+
299+ return new EciCoordinate ( time , new Vector3 ( xKm , yKm , zKm ) ) ;
300+ }
301+
302+ /// <summary>
303+ /// Calculates the Moon's apparent geocentric position, including nutation corrections.
304+ /// This produces coordinates closer to what would be observed visually or compared
305+ /// against JPL Horizons (excluding light-time and topocentric parallax).
306+ /// </summary>
307+ /// <param name="time">The time of observation (UTC).</param>
308+ /// <returns>
309+ /// An EciCoordinate representing the Moon's apparent position as a geocentric
310+ /// equatorial Cartesian vector in the true equator and true equinox of date,
311+ /// in kilometers. Velocity is zero.
312+ /// </returns>
313+ /// <remarks>
314+ /// <para>
315+ /// This method first computes the mean position via <see cref="Predict" />,
316+ /// then applies nutation in longitude and true obliquity (mean + nutation in
317+ /// obliquity) to produce apparent equatorial coordinates.
318+ /// </para>
319+ /// <para>
320+ /// This is still not a J2000/GCRF/TEME state vector. Light-time correction
321+ /// and topocentric parallax are not included.
322+ /// </para>
323+ /// </remarks>
324+ public static EciCoordinate PredictApparent ( DateTime time )
325+ {
326+ var jdUtc = time . ToJulian ( ) ;
327+ var jdTt = LeapSeconds . UtcToTt ( jdUtc ) ;
328+
329+ // Get the mean position first.
330+ var mean = Predict ( time ) ;
331+ var pos = mean . Position ;
332+
333+ // Convert the mean equatorial vector back to ecliptic using mean obliquity.
334+ var epsilonMeanRad = MathUtil . DegreesToRadians ( Obliquity . MeanObliquityDeg ( jdTt ) ) ;
335+ var cosEpsM = Math . Cos ( epsilonMeanRad ) ;
336+ var sinEpsM = Math . Sin ( epsilonMeanRad ) ;
337+
338+ var x = pos . X ;
339+ var y = pos . Y ;
340+ var z = pos . Z ;
341+
342+ // Equatorial to ecliptic (mean of date).
343+ var xEcl = x ;
344+ var yEcl = y * cosEpsM + z * sinEpsM ;
345+ var zEcl = - y * sinEpsM + z * cosEpsM ;
346+
347+ var rho = Math . Sqrt ( xEcl * xEcl + yEcl * yEcl ) ;
348+ var lambdaRad = Math . Atan2 ( yEcl , xEcl ) ;
349+ var betaRad = Math . Atan2 ( zEcl , rho ) ;
350+ var distance = pos . Length ;
351+
352+ // Apply nutation in longitude.
353+ var deltaPsiRad = MathUtil . DegreesToRadians ( Nutation . LongitudeDeg ( jdTt ) ) ;
354+ var lambdaApparent = lambdaRad + deltaPsiRad ;
355+
356+ // Use true obliquity (mean + nutation in obliquity) for apparent coordinates.
357+ var deltaEpsilonRad = MathUtil . DegreesToRadians ( Nutation . ObliquityDeg ( jdTt ) ) ;
358+ var epsilonTrueRad = epsilonMeanRad + deltaEpsilonRad ;
359+ var cosEpsT = Math . Cos ( epsilonTrueRad ) ;
360+ var sinEpsT = Math . Sin ( epsilonTrueRad ) ;
361+
362+ var cosLambdaA = Math . Cos ( lambdaApparent ) ;
363+ var sinLambdaA = Math . Sin ( lambdaApparent ) ;
364+ var cosBeta = Math . Cos ( betaRad ) ;
365+ var sinBeta = Math . Sin ( betaRad ) ;
366+
367+ // Rotate apparent ecliptic to apparent equatorial.
368+ var xApp = distance * cosBeta * cosLambdaA ;
369+ var yApp = distance * ( cosEpsT * cosBeta * sinLambdaA - sinEpsT * sinBeta ) ;
370+ var zApp = distance * ( sinEpsT * cosBeta * sinLambdaA + cosEpsT * sinBeta ) ;
371+
372+ return new EciCoordinate ( time , new Vector3 ( xApp , yApp , zApp ) ) ;
373+ }
374+
375+ private static double Horner ( double t , params double [ ] coeffs )
291376 {
292377 var result = 0.0 ;
293378 for ( var i = coeffs . Length - 1 ; i >= 0 ; i -- )
0 commit comments