DEV Community

Cover image for How I Model Aspects and Orbs in a Python Astrology Engine

How I Model Aspects and Orbs in a Python Astrology Engine

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°
Enter fullscreen mode Exit fullscreen mode

Their separation is 90°.

That’s easy.

But this pair is more interesting:

358°
2°
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Now:

angular_distance(358, 2)
Enter fullscreen mode Exit fullscreen mode

returns:

4
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Then:

if orb <= allowed_orb:
    # aspect matched
Enter fullscreen mode Exit fullscreen mode

I keep orb rules in a profile

This is one of those places where hidden constants are really tempting.

Something like:

MAX_ORB = 8
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

It can be understood as:

Venus square Saturn
under this aspect profile
with this orb
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

It should not calculate:

good
bad
easy
terrible relationship
Enter fullscreen mode Exit fullscreen mode

Those are interpretation-layer concepts.

I don’t want this in the chart model:

{
  "aspect": "square",
  "meaning": "negative"
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Then the structured result can look conceptually like:

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

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)
Enter fullscreen mode Exit fullscreen mode

The distance must always be in:

0° <= distance <= 180°
Enter fullscreen mode Exit fullscreen mode

And wraparound behavior needs direct tests:

359° vs 1° = 2°
Enter fullscreen mode Exit fullscreen mode

Orb boundaries are useful too:

orb == maximum → match
orb > maximum → no match
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

https://github.com/getbirthchart-com/gbc-astro-engine

Top comments (0)