DEV Community

Cover image for Building a Natal Chart Engine in Python: From Birth Data to Planetary Positions

Building a Natal Chart Engine in Python: From Birth Data to Planetary Positions

When I started working on a natal-chart engine, I assumed the interesting part would be zodiac calculations.

It wasn't.

Mapping a longitude to a zodiac sign is one of the easier parts.

The harder problem is building a pipeline where every step has an explicit meaning.

The input a user gives you is usually something like:

November 3, 1992
2:35 PM
Hanoi, Vietnam
Enter fullscreen mode Exit fullscreen mode

The astronomical layer needs something much less human:

UTC instant
latitude
longitude
calculation profile
ephemeris
Enter fullscreen mode Exit fullscreen mode

The job of the engine is to get from one to the other without quietly making decisions the caller didn't ask for.

Step 1: keep local time and timezone separate

One thing I deliberately avoid is treating a local datetime as if it already describes an absolute instant.

This:

1992-11-03 14:35
Enter fullscreen mode Exit fullscreen mode

is incomplete.

You still need a timezone.

In the engine interface, they're separate:

chart = engine.natal(
    local_datetime="1992-11-03T14:35:00",
    timezone="Asia/Ho_Chi_Minh",
    latitude=21.0285,
    longitude=105.8542,
)
Enter fullscreen mode Exit fullscreen mode

The timezone uses an IANA identifier rather than a fixed UTC offset.

That matters because historical timezone and DST rules aren't always equivalent to:

UTC+7
Enter fullscreen mode Exit fullscreen mode

Step 2: convert the local civil time to an astronomical instant

Once the local datetime has been resolved, the calculation layer can convert it to UTC and then to the time representation expected by the ephemeris.

Conceptually:

local civil time
      ↓
timezone rules
      ↓
UTC
      ↓
Julian day
Enter fullscreen mode Exit fullscreen mode

There are edge cases here that are easy to miss.

During a DST fall-back transition, the same clock time may occur twice.

During spring-forward, some clock times don't exist at all.

I prefer rejecting an ambiguous input to silently choosing one possibility.

This is a general engineering principle I've become more attached to:

Ambiguity should be represented in the API, not hidden inside a default.

Step 3: ask an ephemeris for planetary positions

The engine uses Swiss Ephemeris through pyswisseph.

For each supported body, the provider gives us values including ecliptic longitude and longitude speed.

A longitude might conceptually look like:

221.1415°
Enter fullscreen mode Exit fullscreen mode

Now we're finally at the familiar astrology part.

Step 4: longitude → sign + degree

A zodiac is a 360° circle divided into 12 sections of 30°.

So the basic mapping is simple:

sign_index = int(longitude // 30)
degree_in_sign = longitude % 30
Enter fullscreen mode Exit fullscreen mode

For example:

221.1415°
Enter fullscreen mode Exit fullscreen mode

lands in the eighth 30° segment:

Scorpio 11.1415°
Enter fullscreen mode Exit fullscreen mode

This part is deterministic and uncomplicated.

Which is exactly why I don't want an LLM doing it.

Step 5: calculate houses and angles

Now things get more sensitive to time and location.

For a known birth time, the engine can calculate:

  • Ascendant
  • Midheaven
  • Descendant
  • IC
  • house cusps
  • planet-to-house assignments

The default profile uses Placidus, although the core supports multiple house systems.

This is where latitude also becomes important.

Some house systems have geographic limits.

For example, I prefer raising an explicit calculation error in unsupported polar conditions rather than silently switching the user to another house system.

Again: no invisible fallback.

Step 6: derive aspects

Once the body positions are available, aspects become a circular-distance problem.

For two longitudes:

a = 358.0
b = 2.0
Enter fullscreen mode Exit fullscreen mode

the distance is not 356°.

It's 4°.

A common normalization is conceptually:

delta = abs(a - b) % 360
distance = min(delta, 360 - delta)
Enter fullscreen mode Exit fullscreen mode

Then the engine compares the distance to configured aspect angles.

For example:

conjunction   0°
sextile      60°
square       90°
trine       120°
opposition  180°
Enter fullscreen mode Exit fullscreen mode

with different orb allowances.

The engine can then publish structured facts such as:

{
  "type": "square",
  "orb": 2.1,
  "phase": "applying"
}
Enter fullscreen mode Exit fullscreen mode

instead of forcing the interpretation layer to rediscover the geometry.

Step 7: normalize everything into one chart model

This is one of the parts I underestimated.

The astronomy isn't enough.

The application needs a stable contract.

The engine eventually turns the calculations into a normalized chart object containing things like:

subject
meta
bodies
angles
houses
aspects
derived facts
warnings
Enter fullscreen mode Exit fullscreen mode

That model is what the rest of GetBirthChart consumes.

The frontend shouldn't need to know how Swiss Ephemeris works.

The AI layer shouldn't need to know how to normalize circular angles.

The API shouldn't implement its own astrology math.

They all consume the same chart model.

Why this separation is useful

It creates a very clear dependency graph:

Swiss Ephemeris
      ↓
Python calculation core
      ↓
canonical chart model
      ↓
API / UI / interpretation
Enter fullscreen mode Exit fullscreen mode

If a planet position is wrong, I debug the core.

If the API serialized it incorrectly, I debug the transport.

If an interpretation is poor, I debug the interpretation layer.

Without those boundaries, bugs become much harder to classify.

The surprising lesson

The interesting part of an astrology engine isn't really “how do I calculate Scorpio?”

It's how you handle everything around the calculation:

  • civil time
  • DST ambiguity
  • unknown inputs
  • geographic limitations
  • calculation profiles
  • structured errors
  • deterministic output
  • test fixtures

The zodiac mapping is maybe ten lines.

The trustworthiness of the software lives in everything around those ten lines.

Top comments (0)