I like working on aspect calculations because the astrology terminology disappears pretty quickly once you get into the code.
At the calculation level, an aspect is basically:
How close are two points on a circle to a configured angle?
That turns the problem into geometry, tolerances and a few interesting edge cases.
Start with angular distance
Suppose two planets have longitudes:
12°
102°
Their separation is 90°.
That’s easy.
But this pair is more interesting:
358°
2°
A normal absolute difference gives you 356°.
On a circle, they’re actually 4° apart.
So one of the basic utilities looks conceptually like this:
def angular_distance(a: float, b: float) -> float:
delta = abs(a - b) % 360
return min(delta, 360 - delta)
Now:
angular_distance(358, 2)
returns:
4
That simple normalization is the base of the rest of the aspect system.
Then define target angles
For the major aspects, you’re comparing against angles such as:
0° conjunction
60° sextile
90° square
120° trine
180° opposition
If everything had to be exact, the implementation would be trivial.
But astrology uses orbs.
So a separation of 92° can still be treated as a square depending on the calculation profile.
The orb is basically:
orb = abs(actual_distance - target_angle)
Then:
if orb <= allowed_orb:
# aspect matched
I keep orb rules in a profile
This is one of those places where hidden constants are really tempting.
Something like:
MAX_ORB = 8
and move on.
I prefer putting this kind of behavior into an explicit calculation profile.
That way the result isn’t just:
Venus square Saturn
It can be understood as:
Venus square Saturn
under this aspect profile
with this orb
That makes the methodology easier to inspect and makes future changes much less messy.
The engine reports geometry, not sentiment
This was another boundary I wanted to keep clean.
The core can calculate:
planet A
planet B
aspect type
orb
phase
It should not calculate:
good
bad
easy
terrible relationship
Those are interpretation-layer concepts.
I don’t want this in the chart model:
{
"aspect": "square",
"meaning": "negative"
}
The geometry doesn’t know that.
It knows there is a 90-degree relationship within the configured tolerance.
That separation makes the output usable by different interpretation systems later.
Applying vs separating
Once the static aspect is identified, there’s another useful piece of information: motion.
Is the pair moving toward exactness or away from it?
For that you need more than longitude.
You need the bodies’ motion as well.
The ephemeris provides longitudinal speed, which lets the engine derive whether the aspect is:
applying
exact
separating
Then the structured result can look conceptually like:
{
"type": "square",
"orb": 2.1,
"phase": "applying"
}
The application can decide whether and how to interpret that.
Again, the core just reports the calculated state.
This code is nice to test
Aspect math gives you some good invariants.
For example:
distance(a, b) == distance(b, a)
The distance must always be in:
0° <= distance <= 180°
And wraparound behavior needs direct tests:
359° vs 1° = 2°
Orb boundaries are useful too:
orb == maximum → match
orb > maximum → no match
These are the kinds of tests I trust much more than checking a few screenshots from the frontend.
A pattern I keep using
This part of the engine is a good example of how I like to structure domain logic.
Start with the smallest deterministic representation.
Instead of:
Venus square Saturn means relationship restriction.
start with:
Venus longitude
Saturn longitude
angular separation
target angle
allowed tolerance
motion
Get that part right first.
Then let another layer decide what, if anything, the relationship means.
It keeps the calculation code boring.
I mean that as a compliment.
The aspect implementation is part of the open-source GetBirthChart Python engine:
Top comments (0)