Natal Chart Lab

Engineering notes

Checking a Birth Chart Calculator Against JPL Horizons

By Natal Chart Lab ·

We checked 50 calculated planetary longitudes against NASA JPL Horizons. The largest difference in this sample was 19.5 arcseconds. Here is what we compared, how to inspect the results, and what the check leaves unanswered.

A chart needs a calculation you can inspect

A birth chart calculator turns a date, time and place into planetary positions, horizon angles and houses. Those are separate calculations. A plausible zodiac wheel does not tell a reader whether the longitude of Venus is correct, whether a historical clock change was handled, or whether a house system was defined at that latitude.

For Natal Chart Lab, we started with a narrower question: how do the planetary longitudes returned by our calculation code compare with an external ephemeris? The comparison is small enough to inspect row by row. Its purpose is to expose the calculation and its limits, rather than turn a single number into a claim about the whole product.

The calculator and the reference have different jobs

The application calculates positions with Astronomy Engine. It does not make a Horizons request for each birth chart. Our wrapper uses its Sun and Moon routines and converts the geocentric vectors for the other planets to ecliptic coordinates. The sign label then comes from dividing the normalized longitude into twelve 30-degree segments.

JPL Horizons provides the reference positions for this check. We request observer-table quantity 31, which reports apparent observer-centered ecliptic longitude and latitude, and compare the longitude column. We use the Sun, Moon, Mercury, Venus, Mars, Jupiter, Saturn, Uranus, Neptune and Pluto. The mean lunar node and mean lunar apogee used elsewhere in the calculator are outside this sample.

Write down the query before discussing the error

Two ephemeris outputs can disagree because they describe different quantities. An Earth-centered position differs from a position at an observer's location; a geometric vector differs from an apparent direction. Reference epoch, ecliptic convention and time scale also matter. Comparing numbers before recording those choices makes the resulting error hard to interpret.

Our requests use the following settings. The target command changes for each body; the Horizons API documentation explains the parameters.

Settings used in the saved comparison
SettingValuePurpose
EPHEM_TYPEOBSERVERObserver-table output
CENTER500@399Earth's center
QUANTITIES31Ecliptic longitude and latitude
TIME_TYPEUTInput and output time convention
STEP_SIZE1mOne-minute output spacing

The Horizons manual specifies the reference convention and apparent-position corrections for quantity 31. Our wrapper and Horizons are not claimed to implement identical models or every correction identically. The residuals are a comparison of these particular outputs, not an isolated measurement of numerical integration error.

Fifty positions, with the dates made explicit

We evaluated 10 bodies at each of 5 instants, producing 50 comparisons. These are the complete date labels from the saved report:

This is a sparse spot check spanning 1955–2026. It is not a random sample or a continuous scan of that interval. It does not cover the calculator's entire accepted date range, and it cannot establish a worst-case error bound for dates we did not test.

There is also a historical time-scale assumption to retain: the harness constructs JavaScript dates from UTC-formatted strings while requesting Horizons UT. Historical UT conventions and UTC are not interchangeable at arbitrary precision. A stronger historical validation would audit that conversion explicitly; the present check does not independently validate time standards or our local-time conversion.

Longitude differences must wrap around the circle

Ordinary subtraction gives the wrong answer near zero degrees. Longitudes of 359.999° and 0.001° are only 0.002° apart, although their direct numerical difference is almost 360°. We use the shortest signed angular difference, then multiply by 3,600 to express degrees as arcseconds:

const difference = ((engine - reference + 180) % 360 + 360) % 360 - 180;
const differenceArcseconds = difference * 3600;

The saved signed difference is engine minus Horizons, wrapped around the circle. For the summary below we take its absolute value and keep the largest result for each body. The sign of a residual can help with diagnosis, so the downloadable report retains it even though the summary uses magnitudes.

The observed results

Largest absolute longitude difference per body, across five instants
BodyDifference in arcseconds
Sun1.0
Moon4.9
Mercury4.1
Venus19.5
Mars1.6
Jupiter3.8
Saturn10.3
Uranus4.6
Neptune16.8
Pluto2.9

The largest recorded difference is 19.5 arcseconds, for Venus at 2026-10-25 12:00 UT. That is approximately 0.0054 degrees. The report was generated on 2026-10-25; the values above come directly from that saved comparison rather than from a newly run experiment for this article.

A tropical zodiac sign spans 30 degrees, but that scale comparison should not be read as a guarantee that a sign label is always safe. Near a boundary, a small positional or time error can still change a label. Similar caution applies to an event time inferred from a slowly changing longitude.

Inspect the results with standard Python

The complete JSON report includes the query-date label, body, Horizons longitude, engine longitude and signed difference for every row. This example uses only Python's standard library to check the saved maximum and print the per-body results:

import json
import os
from urllib.request import Request, urlopen

base = os.environ.get("NCL_BASE_URL", "https://natalchartlab.com")
request = Request(base + "/data/horizons-validation.json", headers={
    "User-Agent": "NatalChartLabValidationExample/1.0",
    "Accept": "application/json",
})
with urlopen(request, timeout=30) as response:
    report = json.load(response)

rows = report["rows"]
largest = max(abs(row["diffArcsec"]) for row in rows)
assert largest == report["maxAbsDiffArcsec"]
print(f"{len(rows)} positions; largest difference: {largest} arcseconds")

for body in dict.fromkeys(row["body"] for row in rows):
    maximum = max(abs(row["diffArcsec"]) for row in rows
                  if row["body"] == body)
    print(f"{body:8s} {maximum:5.1f}")

The request identifies this example with a descriptive User-Agent. Generic Python request identifiers can be rejected by the site's traffic checks.

It reads the archived results; it does not rerun Horizons. The report stores engine longitude to five decimal places and residuals to one decimal place in arcseconds. Recomputing residuals from the rounded longitude columns can therefore differ slightly from residuals calculated with the original internal precision.

For a fresh comparison, query https://ssd.jpl.nasa.gov/api/horizons.api with the settings above and a specific body's target ID, such as 299 for Venus. Compare the first returned longitude at the requested instant with the same body's result from our positions API. Preserve the response headers, settings and data if you want a new result to be auditable. A future engine or reference update need not reproduce the saved decimals exactly.

What this check does not establish

The result is evidence about 50 longitude comparisons. It does not validate Ascendant or Midheaven calculations, Placidus cusps, historical daylight-saving rules, or the handling of an unknown birth time. Those involve additional inputs, formulas and boundary cases. Each needs its own checks.

It also does not independently validate retrograde station or shadow times. Our retrograde calendar detects changes in apparent motion and refines their times. A position comparison alone does not supply an error bound for those events; reporting a time to the minute describes output resolution, not proven one-minute accuracy.

Finally, an astronomical position check does not test claims about personality, relationships or future events. The site's interpretations describe an astrological tradition. NASA and JPL have not certified or endorsed this calculator.

Making the next comparison more useful

The next useful step is a broader, explicitly designed test set: more dates, cases close to sign boundaries, and separate event-time comparisons around stations. It should record engine versions and raw reference responses, check time-scale assumptions, and distinguish coordinate-convention differences from implementation mistakes. More decimal places in a results table would not replace that work.

For now, readers can inspect the saved rows, try a fresh API comparison, and review the full methodology. The separate 1950–2050 retrograde dataset contains 922 computed intervals for eight planets, including shadow periods, with CC BY 4.0 downloads on the site, Kaggle and Hugging Face. It is a related calculation output, not an additional JPL validation experiment.

This article was prepared with AI assistance and checked against the project's implementation and saved comparison data.