Full-detail natal charts: 21 bodies/points, 7 house systems, 11 aspect types with applying/separating, dignities, declination parallels, sect/balances/moon phase, offline city search with historical IANA time atlas, SVG wheel, and a written interpretation report. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
294 lines
12 KiB
Python
294 lines
12 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,
|
|
}
|
|
|
|
|
|
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
|