DEV Community

massimo minganti
massimo minganti

Posted on

How I built a free astrology synastry calculator with Python and Swiss Ephemeris

When my partner asked me to check our astrological compatibility, I did what any developer would do: I built the tool myself instead of using the existing ones.

The result is Coppiastrale — a free synastry and natal chart calculator for couples, built with Python and real ephemeris data. Here's what I learned.

What is synastry?
Synastry is the astrological technique of comparing two birth charts to assess compatibility. You overlay two natal charts and analyze the geometric angles (aspects) formed between one person's planets and the other's — conjunctions, oppositions, trines, squares, and so on.

The stack
Flask (SSR, not a SPA — SEO was a core requirement)
Kerykeion — a Python library wrapping pyswisseph (Swiss Ephemeris)
GeoNames API — birth place → lat/lng + IANA timezone
svgwrite — for rendering the chart wheels
SQLite + SQLAlchemy — only for the paid flow, the free calculator stores zero PII
The key constraint: the free tool had to be completely stateless. No user accounts, no stored birth data, no cookies beyond a slim session reference.

The hardest part: timezone handling
Swiss Ephemeris needs UTC. Users give you a city name and a local time. The tricky bit is converting correctly — especially for historical dates where DST rules were different.

GeoNames returns real IANA timezone identifiers (e.g. Europe/Rome), which you then pass to pytz or zoneinfo. I initially used a simpler geocoder that returned UTC offsets directly — this caused wrong chart calculations for births before 1970 in several countries. Switching to IANA + zoneinfo fixed it.

from zoneinfo import ZoneInfo
from datetime import datetime

def to_utc(date_str, time_str, iana_tz):
local_dt = datetime.strptime(f"{date_str} {time_str}", "%Y-%m-%d %H:%M")
local_dt = local_dt.replace(tzinfo=ZoneInfo(iana_tz))
return local_dt.astimezone(ZoneInfo("UTC"))
Synastry scoring
Each inter-aspect gets a base score from a YAML config file (conjunction with Venus = high, square with Saturn = low, etc.). I then apply a sigmoid normalization to compress everything into a 0–100 scale that feels intuitive to non-astrologers.

The weights live in a versioned YAML file — not in the DB — so I can tweak the astrological logic without a schema migration.

SVG chart rendering
Kerykeion can generate SVG wheels natively, but I needed a custom dual-wheel layout for synastry (inner wheel = person A, outer wheel = person B, lines between related planets). I ended up using svgwrite to draw the overlay programmatically on top of Kerykeion's base geometry.

The trickiest part: placing planet glyphs at the right angle without overlapping when two planets are within 3° of each other. I added a collision-detection pass that nudges labels radially outward.

SEO as the product moat
The free calculator is the SEO lure. The app generates ~365 indexable pages: 78 canonical sign-pair compatibility pages, 72 planet-in-sign pages, 12 sign profiles, 144 Sun×Ascendant combinations — all with structured data (BreadcrumbList, Article, FAQPage schemas).

Since it's Flask SSR, all of this is crawlable without JavaScript rendering.

What I'd do differently
Don't use Flask sessions for large payloads. Cookies max out at 4KB. I store the full chart JSON in localStorage and only keep a slim reference in the session. Learned this the hard way after charts started silently truncating.

Test timezone edge cases early. Write a test for a birth in Indiana before 2006 (they didn't observe DST). If that passes, you're probably fine.

Try it - and let me know your comments!

The calculator is live at coppiastrale.it (Italian interface, but the math works for anyone). Happy to answer questions about the ephemeris stack or the scoring algorithm.

Top comments (0)