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
The astronomical layer needs something much less human:
UTC instant
latitude
longitude
calculation profile
ephemeris
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
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,
)
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
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
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°
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
For example:
221.1415°
lands in the eighth 30° segment:
Scorpio 11.1415°
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
the distance is not 356°.
It's 4°.
A common normalization is conceptually:
delta = abs(a - b) % 360
distance = min(delta, 360 - delta)
Then the engine compares the distance to configured aspect angles.
For example:
conjunction 0°
sextile 60°
square 90°
trine 120°
opposition 180°
with different orb allowances.
The engine can then publish structured facts such as:
{
"type": "square",
"orb": 2.1,
"phase": "applying"
}
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
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
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)