exupero's blog
RSSApps

Mapping orbital positions

In the previous post I showed how to map an elliptical orbit around the sun, but I didn't show where any planets were in their orbits. Due to the inconstant speed of objects in elliptical orbits, finding their exact positions requires some numerical approximation.

Orbital parameters for a body will usually give three parameters that we can use to determine its location on any date: orbital period, mean anomaly at epoch, and the epoch itself. We'll use the common epoch of J2000, since that's what Wikipedia gives in its sidebars on the planets.

(defn local-date [d]
  (-> d
      (.toInstant)
      (.atZone (java.time.ZoneId/of "UTC"))
      (.toLocalDate)))
(def J2000 (local-date #inst "2000-01-01T12:00:00Z"))

The positional parameters for J2000:

BodyPeriod (days)Mean anomaly at epoch
Mercury87.97174.796°
Venus224.7050.115°
Earth365.26358.617°
Mars686.9819.412°
Jupiter4332.5920.02°
Saturn10759.22317.02°
Uranus30688.50142.2386°
Neptune60195.00256.228°
Pluto90560.0014.53°

The first step in calculating an orbiting body's position is to convert the mean anomaly at the epoch to the mean anomaly on some other date. The mean anomaly indicates how far around the orbit the body would be on a circular orbit, so to find the mean anomaly for another date, we calculate how many orbits the body could have completed since the epoch and add it to the mean anomaly at the epoch:

(import '[java.time.temporal ChronoUnit])
(defn mean-anomaly [{:keys [mean-anomaly-at-epoch period epoch]} time]
  (let [days-difference (.between ChronoUnit/DAYS epoch time)]
    (mod (Math/toDegrees
           (+ (Math/toRadians mean-anomaly-at-epoch)
              (/ (* 2 Math/PI days-difference)
                 period)))
         360)))

Testing it out:

(def today (local-date #inst "2022-11-17T00:00:00Z"))
(map (juxt :name
           #(mean-anomaly % today))
     bodies)
(["Mercury" 170.4396976165517]
 ["Venus" 117.50535429303818]
 ["Earth" 314.3667491893302]
 ["Mars" 78.2294328219159]
 ["Jupiter" 354.3298700777133]
 ["Saturn" 236.6090408412506]
 ["Uranus" 240.2609862358864]
 ["Neptune" 306.2015858460005]
 ["Pluto" 47.74731448763251])

Before we can get the actual angle of a body around the sun, we need to convert mean anomaly to eccentric anomaly, which is the angle of the body on an ellipse instead of a circle, but with the sun at the center instead of one focus. While the eccentric anomaly can be used to calculate the mean anomaly with

it's not so easy to solve for E, so we'll have to find it numerically. We can use Newton's method, providing the mean anomaly as an initial guess:

(defn newtons-method [f f' x0 tolerance]
  (loop [x x0]
    (let [x' (- x (/ (f x) (f' x)))]
      (if (< tolerance (Math/abs (- x x')))
        (recur x')
        x'))))
(defn eccentric-anomaly [mean-anomaly eccentricity]
  (let [mean-anomaly (Math/toRadians mean-anomaly)]
    (Math/toDegrees
      (newtons-method
        #(- % (* eccentricity (Math/sin %)) mean-anomaly)
        #(- 1 (* eccentricity (Math/cos %)))
        mean-anomaly
        1e-7))))

The values are only slightly different, even for more eccentric Mercury and Pluto:

(map (juxt :name
           (fn [{:keys [eccentricity] :as body}]
             (-> body
                 (mean-anomaly today)
                 (eccentric-anomaly eccentricity))))
     bodies)
(["Mercury" 172.06596434790993]
 ["Venus" 117.84842485398484]
 ["Earth" 313.67433319664093]
 ["Mars" 83.54695355396832]
 ["Jupiter" 354.0388977811991]
 ["Saturn" 233.9904005230953]
 ["Uranus" 237.96977181287355]
 ["Neptune" 305.79830595492984]
 ["Pluto" 60.10581321804513])

With the eccentric anomaly we can finally calculate the true anomaly, which is the actual angle of a body in an elliptical orbit with the sun at one focus. The calculation is

I've chosen this form not just for the symmetry of the expression but also because this fractional form allows us to use Java's Math.atan2 function to avoid quadrant calculations:

(defn true-anomaly [eccentric-anomaly eccentricity]
  (let [eccentric-anomaly (Math/toRadians eccentric-anomaly)]
    (Math/toDegrees
      (* 2 (Math/atan2 (* (Math/sqrt (+ 1 eccentricity))
                          (Math/sin (/ eccentric-anomaly 2)))
                       (* (Math/sqrt (- 1 eccentricity))
                          (Math/cos (/ eccentric-anomaly 2))))))))
(map (juxt :name
           (fn [{:keys [eccentricity] :as body}]
             (-> body
                 (mean-anomaly today)
                 (eccentric-anomaly eccentricity)
                 (true-anomaly eccentricity))))
     bodies)
(["Mercury" 173.55630152670443]
 ["Venus" 118.19095653246261]
 ["Earth" 312.97785906507903]
 ["Mars" 88.90040394221349]
 ["Jupiter" 353.74049105531617]
 ["Saturn" 231.4129514495962]
 ["Uranus" 235.7059134207224]
 ["Neptune" 305.3939939703652]
 ["Pluto" 73.44522413262469])

Let's finally draw bodies in their correct positions. Here are a couple functions similar to ellipse and orbit from the previous post:

(defn elliptical-position [{:keys [semi-major-axis eccentricity] :as body} time]
  (let [true-anom (-> body
                      (mean-anomaly time)
                      (eccentric-anomaly eccentricity)
                      (true-anomaly eccentricity))
        dist (distance semi-major-axis eccentricity)]
    (polar->cartesian (dist true-anom) true-anom)))
(defn orbit-position
  [{:keys [argument-of-perihelion
           inclination
           longitude-of-ascending-node]
    :as body}
   time]
  (->> (elliptical-position body time)
       (transform (rotate-z argument-of-perihelion))
       (transform (rotate-x inclination))
       (transform (rotate-z longitude-of-ascending-node))))

To check our work, compare the above view with WolframAlpha or a more sophisticated map for November 17, 2022.