nategodd/chart.py
type-two 9c284edc9f Synastry, exports, aspect education
- /api/synastry: inter-chart aspects with weighted harmony/tension
  scores, house overlays, written compatibility report
- Compatibility tab: biwheel (partner ring in gold), inter-aspect grid,
  score gauges
- Export toolbar: chart .json (re-loadable via Load file), wheel SVG/PNG,
  print CSS covers synastry
- Aspect types explained reference + pair notes for 30+ classic pairs
  woven into natal aspect texts
- Wheel fixes: correct arc sweep direction, text-presentation zodiac
  glyphs (no more emoji rendering)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 20:23:19 +10:00

373 lines
15 KiB
Python

"""NATEGODD chart engine — Swiss Ephemeris natal chart computation."""
import os
import swisseph as swe
swe.set_ephe_path(os.path.join(os.path.dirname(os.path.abspath(__file__)), "ephe"))
FLG = swe.FLG_SWIEPH | swe.FLG_SPEED
SIGNS = ["Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces"]
SIGN_GLYPHS = ["", "", "", "", "", "", "", "", "", "", "", ""]
ELEMENTS = ["Fire", "Earth", "Air", "Water"] * 3 # index by sign % 4... careful: use sign_index % 4 mapping below
SIGN_ELEMENT = ["Fire", "Earth", "Air", "Water"] # sign i -> SIGN_ELEMENT[i % 4]
SIGN_MODE = ["Cardinal", "Fixed", "Mutable"] # sign i -> SIGN_MODE[i % 3]
BODIES = [
("Sun", swe.SUN, ""),
("Moon", swe.MOON, ""),
("Mercury", swe.MERCURY, ""),
("Venus", swe.VENUS, ""),
("Mars", swe.MARS, ""),
("Jupiter", swe.JUPITER, ""),
("Saturn", swe.SATURN, ""),
("Uranus", swe.URANUS, ""),
("Neptune", swe.NEPTUNE, ""),
("Pluto", swe.PLUTO, ""),
("Chiron", swe.CHIRON, ""),
("Ceres", swe.CERES, ""),
("Pallas", swe.PALLAS, ""),
("Juno", swe.JUNO, ""),
("Vesta", swe.VESTA, ""),
("North Node", swe.TRUE_NODE, ""),
("Lilith", swe.MEAN_APOG, ""),
]
HOUSE_SYSTEMS = {
"P": "Placidus", "K": "Koch", "W": "Whole Sign", "E": "Equal",
"O": "Porphyry", "C": "Campanus", "R": "Regiomontanus",
}
# aspect name -> (angle, base orb, is_major)
ASPECTS = {
"Conjunction": (0, 8.0, True),
"Opposition": (180, 8.0, True),
"Trine": (120, 7.0, True),
"Square": (90, 7.0, True),
"Sextile": (60, 5.0, True),
"Quincunx": (150, 3.0, False),
"Semisextile": (30, 2.0, False),
"Semisquare": (45, 2.0, False),
"Sesquiquadrate": (135, 2.0, False),
"Quintile": (72, 1.5, False),
"Biquintile": (144, 1.5, False),
}
ASPECT_GLYPHS = {
"Conjunction": "", "Opposition": "", "Trine": "", "Square": "",
"Sextile": "", "Quincunx": "", "Semisextile": "", "Semisquare": "",
"Sesquiquadrate": "", "Quintile": "Q", "Biquintile": "bQ",
}
# Essential dignities (traditional rulers; modern rulers noted separately)
RULERS = { # sign index -> traditional ruler
0: "Mars", 1: "Venus", 2: "Mercury", 3: "Moon", 4: "Sun", 5: "Mercury",
6: "Venus", 7: "Mars", 8: "Jupiter", 9: "Saturn", 10: "Saturn", 11: "Jupiter",
}
MODERN_RULERS = {7: "Pluto", 10: "Uranus", 11: "Neptune"}
EXALTATIONS = {"Sun": 0, "Moon": 1, "Mercury": 5, "Venus": 11, "Mars": 9,
"Jupiter": 3, "Saturn": 6, "North Node": 2}
def norm(deg):
return deg % 360.0
def fmt_dms(lon):
"""248.37 -> {'sign': 'Sagittarius', 'deg': 8, 'min': 22, 'sec': 12, 'text': "8°22'12\" Sagittarius"}"""
lon = norm(lon)
si = int(lon // 30)
rem = lon - si * 30
d = int(rem)
m_f = (rem - d) * 60
m = int(m_f)
s = int(round((m_f - m) * 60))
if s == 60:
s = 0
m += 1
if m == 60:
m = 0
d += 1
return {"sign": SIGNS[si], "sign_glyph": SIGN_GLYPHS[si], "sign_index": si,
"deg": d, "min": m, "sec": s,
"text": f"{d}°{m:02d}'{s:02d}\" {SIGNS[si]}"}
def house_of(lon, cusps):
"""Which house (1-12) a longitude falls in, given 12 cusp longitudes."""
lon = norm(lon)
for i in range(12):
a, b = cusps[i], cusps[(i + 1) % 12]
if a <= b:
if a <= lon < b:
return i + 1
else: # wraps 360
if lon >= a or lon < b:
return i + 1
return 12
def dignity(name, sign_index):
out = []
if RULERS.get(sign_index) == name:
out.append("Domicile")
if MODERN_RULERS.get(sign_index) == name:
out.append("Domicile (modern)")
if RULERS.get((sign_index + 6) % 12) == name or MODERN_RULERS.get((sign_index + 6) % 12) == name:
out.append("Detriment")
if EXALTATIONS.get(name) == sign_index:
out.append("Exaltation")
if name in EXALTATIONS and EXALTATIONS[name] == (sign_index + 6) % 12:
out.append("Fall")
return out
def compute_chart(year, month, day, hour, minute, second, ut_offset_hours,
lat, lon, house_system="P", time_unknown=False):
"""All angles in degrees. ut_offset_hours: local = UT + offset (e.g. +10 for AEST)."""
ut_hour = hour + minute / 60.0 + second / 3600.0 - ut_offset_hours
jd = swe.julday(year, month, day, ut_hour)
if time_unknown:
house_system = "W" # degrees meaningless for angles; solar whole-sign fallback handled below
hs = house_system.encode() if isinstance(house_system, str) else house_system
cusps_raw, ascmc = swe.houses(jd, lat, lon, hs)
cusps = [norm(c) for c in cusps_raw[:12]]
asc, mc = norm(ascmc[0]), norm(ascmc[1])
vertex = norm(ascmc[3])
bodies = []
lons = {}
speeds = {}
for name, pid, glyph in BODIES:
try:
pos, _ = swe.calc_ut(jd, pid, FLG)
eq, _ = swe.calc_ut(jd, pid, FLG | swe.FLG_EQUATORIAL)
except swe.Error:
continue
L = norm(pos[0])
lons[name] = L
speeds[name] = pos[3]
bodies.append({
"name": name, "glyph": glyph, "lon": L, "lat": pos[1],
"speed": pos[3], "retrograde": pos[3] < 0,
"declination": eq[1],
"position": fmt_dms(L),
"house": house_of(L, cusps),
"dignities": dignity(name, int(L // 30)),
})
# South Node = opposite the North Node
if "North Node" in lons:
sn = norm(lons["North Node"] + 180)
bodies.append({"name": "South Node", "glyph": "", "lon": sn, "lat": 0.0,
"speed": speeds["North Node"], "retrograde": speeds["North Node"] < 0,
"declination": None, "position": fmt_dms(sn),
"house": house_of(sn, cusps), "dignities": []})
lons["South Node"] = sn
# Day/night chart: Sun in houses 7-12 (above horizon) = day
sun_house = house_of(lons["Sun"], cusps) if "Sun" in lons else 1
is_day = 7 <= sun_house <= 12
# Part of Fortune
if "Sun" in lons and "Moon" in lons:
pof = norm(asc + lons["Moon"] - lons["Sun"]) if is_day else norm(asc + lons["Sun"] - lons["Moon"])
bodies.append({"name": "Part of Fortune", "glyph": "", "lon": pof, "lat": 0.0,
"speed": 0.0, "retrograde": False, "declination": None,
"position": fmt_dms(pof), "house": house_of(pof, cusps), "dignities": []})
lons["Part of Fortune"] = pof
angles = [
{"name": "Ascendant", "glyph": "AC", "lon": asc, "position": fmt_dms(asc)},
{"name": "Midheaven", "glyph": "MC", "lon": mc, "position": fmt_dms(mc)},
{"name": "Descendant", "glyph": "DC", "lon": norm(asc + 180), "position": fmt_dms(norm(asc + 180))},
{"name": "Imum Coeli", "glyph": "IC", "lon": norm(mc + 180), "position": fmt_dms(norm(mc + 180))},
{"name": "Vertex", "glyph": "Vx", "lon": vertex, "position": fmt_dms(vertex)},
]
lons["Ascendant"] = asc
lons["Midheaven"] = mc
aspects = compute_aspects(lons, speeds, time_unknown)
# Balances (classic 10 planets only, weighted)
weights = {"Sun": 2, "Moon": 2, "Mercury": 1, "Venus": 1, "Mars": 1,
"Jupiter": 1, "Saturn": 1, "Uranus": 1, "Neptune": 1, "Pluto": 1}
elem = {"Fire": 0, "Earth": 0, "Air": 0, "Water": 0}
mode = {"Cardinal": 0, "Fixed": 0, "Mutable": 0}
polarity = {"Positive": 0, "Negative": 0}
for name, w in weights.items():
if name not in lons:
continue
si = int(lons[name] // 30)
elem[SIGN_ELEMENT[si % 4]] += w
mode[SIGN_MODE[si % 3]] += w
polarity["Positive" if si % 2 == 0 else "Negative"] += w
# Declination parallels / contraparallels (orb 1°)
decls = {b["name"]: b["declination"] for b in bodies if b["declination"] is not None}
parallels = []
names_d = list(decls)
for i in range(len(names_d)):
for j in range(i + 1, len(names_d)):
d1, d2 = decls[names_d[i]], decls[names_d[j]]
if abs(d1 - d2) <= 1.0:
parallels.append({"a": names_d[i], "b": names_d[j], "type": "Parallel",
"orb": round(abs(d1 - d2), 2)})
elif abs(d1 + d2) <= 1.0:
parallels.append({"a": names_d[i], "b": names_d[j], "type": "Contraparallel",
"orb": round(abs(d1 + d2), 2)})
# Moon phase
phase = None
if "Sun" in lons and "Moon" in lons:
d = norm(lons["Moon"] - lons["Sun"])
phases = ["New Moon", "Waxing Crescent", "First Quarter", "Waxing Gibbous",
"Full Moon", "Waning Gibbous", "Last Quarter", "Waning Crescent"]
phase = {"angle": round(d, 2), "name": phases[int(((d + 22.5) % 360) // 45)],
"illumination": round((1 - __import__("math").cos(__import__("math").radians(d))) / 2 * 100, 1)}
houses = [{"num": i + 1, "lon": c, "position": fmt_dms(c)} for i, c in enumerate(cusps)]
return {
"julian_day": jd,
"house_system": HOUSE_SYSTEMS.get(house_system, house_system),
"time_unknown": time_unknown,
"is_day_chart": is_day,
"bodies": bodies,
"angles": angles,
"houses": houses,
"aspects": aspects,
"parallels": parallels,
"balances": {"elements": elem, "modalities": mode, "polarities": polarity},
"moon_phase": phase,
}
SYN_POINTS = ["Sun", "Moon", "Mercury", "Venus", "Mars", "Jupiter", "Saturn",
"Uranus", "Neptune", "Pluto", "Chiron", "North Node", "Ascendant", "Midheaven"]
SYN_PLANET_WEIGHT = {"Sun": 3.0, "Moon": 3.0, "Venus": 2.5, "Mars": 2.5, "Ascendant": 2.5,
"Mercury": 2.0, "Jupiter": 1.5, "Saturn": 1.5, "North Node": 1.5,
"Uranus": 1.0, "Neptune": 1.0, "Pluto": 1.0, "Chiron": 1.0, "Midheaven": 1.0}
SOFT_SET = {"Sun", "Moon", "Mercury", "Venus", "Jupiter"}
def _syn_lons(chart):
lons = {b["name"]: b["lon"] for b in chart["bodies"]}
if not chart.get("time_unknown"):
for a in chart["angles"]:
lons[a["name"]] = a["lon"]
return {p: lons[p] for p in SYN_POINTS if p in lons}
def compute_synastry(chart_a, chart_b):
"""Inter-chart aspects, house overlays and a weighted compatibility score."""
la, lb = _syn_lons(chart_a), _syn_lons(chart_b)
inter = []
harmony = tension = 0.0
for pa, lona in la.items():
for pb, lonb in lb.items():
sep = abs(norm(lona) - norm(lonb))
if sep > 180:
sep = 360 - sep
best = None
for name, (angle, orb, major) in ASPECTS.items():
o = (orb - 1.0) if major else 1.5 # synastry orbs a touch tighter
if major and (pa in LUMINARIES or pb in LUMINARIES):
o += 1.0
diff = abs(sep - angle)
if diff <= o and (best is None or diff < best[1]):
best = (name, diff, angle, major, o)
if not best:
continue
name, orbv, angle, major, omax = best
if name == "Conjunction":
val = 0.9 if (pa in SOFT_SET and pb in SOFT_SET) else 0.25
else:
val = {"Trine": 1.0, "Sextile": 0.8, "Square": -1.0, "Opposition": -0.7,
"Quincunx": -0.4, "Semisextile": 0.15, "Semisquare": -0.25,
"Sesquiquadrate": -0.25, "Quintile": 0.3, "Biquintile": 0.3}[name]
w = (SYN_PLANET_WEIGHT.get(pa, 1.0) + SYN_PLANET_WEIGHT.get(pb, 1.0)) / 2
score = val * w * (1.0 - (orbv / omax) * 0.5)
if score >= 0:
harmony += score
else:
tension += -score
inter.append({"a": pa, "b": pb, "aspect": name, "glyph": ASPECT_GLYPHS[name],
"orb": round(orbv, 2), "major": major, "score": round(score, 2),
"weight": round(abs(score), 2)})
inter.sort(key=lambda x: -x["weight"])
overlays = {"a_in_b": [], "b_in_a": []}
if not chart_b.get("time_unknown"):
cusps_b = [h["lon"] for h in chart_b["houses"]]
overlays["a_in_b"] = [{"body": b["name"], "glyph": b["glyph"],
"house": house_of(b["lon"], cusps_b)}
for b in chart_a["bodies"] if b["name"] in SYN_PLANET_WEIGHT]
if not chart_a.get("time_unknown"):
cusps_a = [h["lon"] for h in chart_a["houses"]]
overlays["b_in_a"] = [{"body": b["name"], "glyph": b["glyph"],
"house": house_of(b["lon"], cusps_a)}
for b in chart_b["bodies"] if b["name"] in SYN_PLANET_WEIGHT]
total = harmony + tension
return {
"interaspects": inter,
"overlays": overlays,
"scores": {
"harmony": round(harmony, 1),
"tension": round(tension, 1),
"blend": round(100 * harmony / total, 0) if total else 50,
"contacts": len(inter),
},
}
ASPECT_POINTS = [b[0] for b in BODIES] + ["South Node", "Part of Fortune", "Ascendant", "Midheaven"]
LUMINARIES = {"Sun", "Moon"}
MINOR_POINTS = {"Ceres", "Pallas", "Juno", "Vesta", "Lilith", "Part of Fortune", "South Node"}
def compute_aspects(lons, speeds, time_unknown=False):
pts = [p for p in ASPECT_POINTS if p in lons]
if time_unknown:
pts = [p for p in pts if p not in ("Ascendant", "Midheaven", "Part of Fortune")]
out = []
for i in range(len(pts)):
for j in range(i + 1, len(pts)):
a, b = pts[i], pts[j]
if {a, b} == {"North Node", "South Node"}:
continue
sep = abs(norm(lons[a]) - norm(lons[b]))
if sep > 180:
sep = 360 - sep
best = None
for name, (angle, orb, major) in ASPECTS.items():
o = orb
if major and (a in LUMINARIES or b in LUMINARIES):
o += 1.0
if (a in MINOR_POINTS or b in MINOR_POINTS):
o = min(o, 3.0)
if a in ("Ascendant", "Midheaven") or b in ("Ascendant", "Midheaven"):
o = min(o, 6.0) if major else min(o, 2.0)
diff = abs(sep - angle)
if diff <= o and (best is None or diff < best[1]):
best = (name, diff, angle, major)
if best:
name, orbv, angle, major = best
sa, sb = speeds.get(a, 0.0), speeds.get(b, 0.0)
applying = None
if sa or sb:
# faster body approaching exact angle?
cur = norm(lons[a] - lons[b])
if cur > 180:
cur = 360 - cur
rel = -(sa - sb)
else:
rel = sa - sb
applying = (cur < angle and rel > 0) or (cur > angle and rel < 0)
out.append({"a": a, "b": b, "aspect": name, "glyph": ASPECT_GLYPHS[name],
"angle": angle, "orb": round(orbv, 2), "major": major,
"applying": applying})
out.sort(key=lambda x: (not x["major"], x["orb"]))
return out