"""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)] patterns = detect_patterns(lons, aspects) return { "patterns": patterns, "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, } PATTERN_BODIES = ["Sun", "Moon", "Mercury", "Venus", "Mars", "Jupiter", "Saturn", "Uranus", "Neptune", "Pluto", "Chiron"] STELLIUM_BODIES = PATTERN_BODIES + ["North Node"] def detect_patterns(lons, aspects): """Find classic aspect configurations among the main bodies.""" from itertools import combinations names = [n for n in PATTERN_BODIES if n in lons] amap = {} for a in aspects: if a["a"] in PATTERN_BODIES and a["b"] in PATTERN_BODIES: amap[frozenset((a["a"], a["b"]))] = a["aspect"] def asp(x, y): return amap.get(frozenset((x, y))) patterns = [] # Stellium: 3+ bodies in one sign by_sign = {} for n in STELLIUM_BODIES: if n in lons: by_sign.setdefault(int(lons[n] // 30), []).append(n) for si, group in sorted(by_sign.items()): if len(group) >= 3: group.sort(key=lambda n: lons[n]) patterns.append({"type": "Stellium", "members": group, "flavor": SIGNS[si]}) # Grand Trine: 3 mutually trine grand_trines = [] for c in combinations(names, 3): if all(asp(x, y) == "Trine" for x, y in combinations(c, 2)): elems = {SIGN_ELEMENT[int(lons[n] // 30) % 4] for n in c} grand_trines.append(set(c)) patterns.append({"type": "Grand Trine", "members": list(c), "flavor": elems.pop() if len(elems) == 1 else "Mixed"}) # Kite: grand trine + a 4th body opposite one member, sextile the other two for gt in grand_trines: for d in names: if d in gt: continue for m in gt: others = gt - {m} if asp(d, m) == "Opposition" and all(asp(d, o) == "Sextile" for o in others): patterns.append({"type": "Kite", "members": sorted(gt) + [d], "flavor": f"apex {d}"}) # Grand Cross: 4 bodies, 2 oppositions + 4 squares grand_crosses = [] for c in combinations(names, 4): kinds = [asp(x, y) for x, y in combinations(c, 2)] if None in kinds: continue if kinds.count("Opposition") == 2 and kinds.count("Square") == 4: grand_crosses.append(set(c)) modes = {SIGN_MODE[int(lons[n] // 30) % 3] for n in c} patterns.append({"type": "Grand Cross", "members": list(c), "flavor": modes.pop() if len(modes) == 1 else "Mixed"}) # T-Square: opposition, both ends square an apex (skip subsets of a grand cross) for c in combinations(names, 3): if any(set(c) <= gc for gc in grand_crosses): continue for apex in c: ends = [n for n in c if n != apex] if asp(*ends) == "Opposition" and all(asp(apex, e) == "Square" for e in ends): patterns.append({"type": "T-Square", "members": [ends[0], ends[1], apex], "flavor": f"apex {apex}"}) break # Mystic Rectangle: 2 oppositions + 2 trines + 2 sextiles for c in combinations(names, 4): kinds = [asp(x, y) for x, y in combinations(c, 2)] if None in kinds: continue if kinds.count("Opposition") == 2 and kinds.count("Trine") == 2 and kinds.count("Sextile") == 2: patterns.append({"type": "Mystic Rectangle", "members": list(c), "flavor": ""}) # Yod: two quincunxes to an apex from a sextile pair for c in combinations(names, 3): for apex in c: ends = [n for n in c if n != apex] if asp(*ends) == "Sextile" and all(asp(apex, e) == "Quincunx" for e in ends): patterns.append({"type": "Yod", "members": [ends[0], ends[1], apex], "flavor": f"apex {apex}"}) break return patterns 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