"""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