|
@@ -0,0 +1,545 @@
|
|
|
|
|
+"""
|
|
|
|
|
+Pure astrological calculation module.
|
|
|
|
|
+
|
|
|
|
|
+No I/O, no async -- just math on ecliptic longitudes and latitudes.
|
|
|
|
|
+All functions operate on plain dicts/lists for easy testing.
|
|
|
|
|
+
|
|
|
|
|
+Input conventions:
|
|
|
|
|
+ - All longitudes in degrees (0-360 ecliptic)
|
|
|
|
|
+ - All latitudes in degrees (-90 to +90 ecliptic)
|
|
|
|
|
+ - Sidereal time in hours (0-24)
|
|
|
|
|
+ - All angles in degrees (0-360) unless noted
|
|
|
|
|
+
|
|
|
|
|
+Output conventions:
|
|
|
|
|
+ - Zodiac positions: {"sign": str, "degree": float} where degree is 0-30 within sign
|
|
|
|
|
+ - House cusps: array of 12 {"sign": str, "degree": float} (cusp 1 = ASC, cusp 10 = MC)
|
|
|
|
|
+ - Aspects: (body1, body2, aspect_name, orb, applying|separating, exactness)
|
|
|
|
|
+ - Angles: {asc: {sign, degree}, mc: {sign, degree}, dsc: {sign, degree}, ic: {sign, degree}}
|
|
|
|
|
+"""
|
|
|
|
|
+
|
|
|
|
|
+from __future__ import annotations
|
|
|
|
|
+
|
|
|
|
|
+import math
|
|
|
|
|
+from typing import Any
|
|
|
|
|
+
|
|
|
|
|
+# ── Zodiac Signs ─────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ZODIAC_SIGNS = [
|
|
|
|
|
+ "Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
|
|
|
|
|
+ "Libra", "Scorpius", "Sagittarius", "Capricornus", "Aquarius", "Pisces",
|
|
|
|
|
+]
|
|
|
|
|
+
|
|
|
|
|
+SIGN_ABBREVIATIONS = [
|
|
|
|
|
+ "Ar", "Ta", "Ge", "Cn", "Le", "Vi",
|
|
|
|
|
+ "Li", "Sc", "Sg", "Cp", "Aq", "Pi",
|
|
|
|
|
+]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def normalize_degrees(lon: float) -> float:
|
|
|
|
|
+ """Normalize longitude to 0-360."""
|
|
|
|
|
+ return lon % 360.0
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def ecliptic_to_zodiac(lon: float) -> dict[str, Any]:
|
|
|
|
|
+ """Convert ecliptic longitude to zodiac sign + degree within sign.
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ lon: Ecliptic longitude in degrees (0-360, tropical).
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ {"sign": str, "abbreviation": str, "degree": float, "absolute_lon": float}
|
|
|
|
|
+ """
|
|
|
|
|
+ lon = normalize_degrees(lon)
|
|
|
|
|
+ sign_index = int(lon // 30)
|
|
|
|
|
+ degree = lon - (sign_index * 30)
|
|
|
|
|
+ return {
|
|
|
|
|
+ "sign": ZODIAC_SIGNS[sign_index],
|
|
|
|
|
+ "abbreviation": SIGN_ABBREVIATIONS[sign_index],
|
|
|
|
|
+ "degree": round(degree, 6),
|
|
|
|
|
+ "absolute_lon": round(lon, 6),
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def zodiac_to_ecliptic(sign: str, degree: float) -> float:
|
|
|
|
|
+ """Convert zodiac sign + degree to ecliptic longitude."""
|
|
|
|
|
+ sign_index = ZODIAC_SIGNS.index(sign.capitalize())
|
|
|
|
|
+ return normalize_degrees(sign_index * 30 + degree)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ── Aspect Definitions ───────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ASPECT_DEFINITIONS: list[dict[str, Any]] = [
|
|
|
|
|
+ {"name": "conjunction", "angle": 0.0, "default_orb": 8.0, "symbol": "Co"},
|
|
|
|
|
+ {"name": "sextile", "angle": 60.0, "default_orb": 6.0, "symbol": "Sx"},
|
|
|
|
|
+ {"name": "square", "angle": 90.0, "default_orb": 8.0, "symbol": "Sq"},
|
|
|
|
|
+ {"name": "trine", "angle": 120.0, "default_orb": 8.0, "symbol": "Tr"},
|
|
|
|
|
+ {"name": "opposition", "angle": 180.0, "default_orb": 8.0, "symbol": "Op"},
|
|
|
|
|
+]
|
|
|
|
|
+
|
|
|
|
|
+DEFAULT_ORBS: dict[str, float] = {
|
|
|
|
|
+ a["name"]: a["default_orb"] for a in ASPECT_DEFINITIONS
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+# Slow planets get wider orbs for transit significance
|
|
|
|
|
+SLOW_PLANETS = {"jupiter", "saturn", "uranus", "neptune", "pluto"}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def compute_aspects(
|
|
|
|
|
+ bodies: list[dict[str, Any]],
|
|
|
|
|
+ orb_limits: dict[str, float] | None = None,
|
|
|
|
|
+) -> list[dict[str, Any]]:
|
|
|
|
|
+ """Compute all aspects between a set of bodies.
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ bodies: List of dicts with at least {"name": str, "lon": float}.
|
|
|
|
|
+ Optionally "speed_lon": float for applying/separating detection.
|
|
|
|
|
+ orb_limits: Optional dict of {aspect_name: max_orb_degrees}.
|
|
|
|
|
+ Defaults to standard orbs.
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ List of aspect dicts, sorted by orb (tightest first).
|
|
|
|
|
+ Each aspect: {
|
|
|
|
|
+ "body1": str,
|
|
|
|
|
+ "body2": str,
|
|
|
|
|
+ "aspect": str,
|
|
|
|
|
+ "orb": float, # degrees from exact
|
|
|
|
|
+ "applying": bool, # True if applying, False if separating
|
|
|
|
|
+ "exactness": float, # 1.0 = exact, 0.0 = at orb limit
|
|
|
|
|
+ "angle": float, # the theoretical angle (0, 60, 90, 120, 180)
|
|
|
|
|
+ }
|
|
|
|
|
+ """
|
|
|
|
|
+ if orb_limits is None:
|
|
|
|
|
+ orb_limits = DEFAULT_ORBS.copy()
|
|
|
|
|
+ else:
|
|
|
|
|
+ orb_limits = {**DEFAULT_ORBS, **orb_limits}
|
|
|
|
|
+
|
|
|
|
|
+ aspects: list[dict[str, Any]] = []
|
|
|
|
|
+
|
|
|
|
|
+ for i, b1 in enumerate(bodies):
|
|
|
|
|
+ for b2 in bodies[i + 1:]:
|
|
|
|
|
+ lon1 = normalize_degrees(b1["lon"])
|
|
|
|
|
+ lon2 = normalize_degrees(b2["lon"])
|
|
|
|
|
+ diff = abs(lon1 - lon2)
|
|
|
|
|
+ diff = min(diff, 360.0 - diff)
|
|
|
|
|
+
|
|
|
|
|
+ for asp_def in ASPECT_DEFINITIONS:
|
|
|
|
|
+ asp_name = asp_def["name"]
|
|
|
|
|
+ asp_angle = asp_def["angle"]
|
|
|
|
|
+ max_orb = orb_limits.get(asp_name, asp_def["default_orb"])
|
|
|
|
|
+ orb = abs(diff - asp_angle)
|
|
|
|
|
+
|
|
|
|
|
+ if orb <= max_orb:
|
|
|
|
|
+ # Determine applying/separating
|
|
|
|
|
+ applying = _is_applying(lon1, lon2, b1.get("speed_lon"), b2.get("speed_lon"), asp_angle)
|
|
|
|
|
+
|
|
|
|
|
+ exactness = 1.0 - (orb / max_orb)
|
|
|
|
|
+
|
|
|
|
|
+ aspects.append({
|
|
|
|
|
+ "body1": b1["name"],
|
|
|
|
|
+ "body2": b2["name"],
|
|
|
|
|
+ "aspect": asp_name,
|
|
|
|
|
+ "orb": round(orb, 6),
|
|
|
|
|
+ "applying": applying,
|
|
|
|
|
+ "exactness": round(max(0.0, exactness), 6),
|
|
|
|
|
+ "angle": asp_angle,
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ # Sort by orb (tightest aspects first)
|
|
|
|
|
+ aspects.sort(key=lambda a: a["orb"])
|
|
|
|
|
+ return aspects
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _is_applying(
|
|
|
|
|
+ lon1: float, lon2: float,
|
|
|
|
|
+ speed1: float | None, speed2: float | None,
|
|
|
|
|
+ target_angle: float,
|
|
|
|
|
+) -> bool | None:
|
|
|
|
|
+ """Determine if an aspect is applying or separating.
|
|
|
|
|
+
|
|
|
|
|
+ Returns True if applying, False if separating, None if speeds unknown.
|
|
|
|
|
+ """
|
|
|
|
|
+ if speed1 is None or speed2 is None:
|
|
|
|
|
+ return None
|
|
|
|
|
+
|
|
|
|
|
+ current_diff = abs(lon1 - lon2)
|
|
|
|
|
+ current_diff = min(current_diff, 360.0 - current_diff)
|
|
|
|
|
+
|
|
|
|
|
+ # Project positions forward by 0.1 day
|
|
|
|
|
+ fwd1 = normalize_degrees(lon1 + speed1 * 0.1)
|
|
|
|
|
+ fwd2 = normalize_degrees(lon2 + speed2 * 0.1)
|
|
|
|
|
+ fwd_diff = abs(fwd1 - fwd2)
|
|
|
|
|
+ fwd_diff = min(fwd_diff, 360.0 - fwd_diff)
|
|
|
|
|
+
|
|
|
|
|
+ target = target_angle
|
|
|
|
|
+ # For opposition, also account for the 180 point
|
|
|
|
|
+ current_dist_to_exact = abs(current_diff - target)
|
|
|
|
|
+ fwd_dist_to_exact = abs(fwd_diff - target)
|
|
|
|
|
+
|
|
|
|
|
+ return fwd_dist_to_exact < current_dist_to_exact
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ── House Systems ────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+SUPPORTED_HOUSE_SYSTEMS = ["placidus", "equal", "whole_sign"]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def calculate_houses(
|
|
|
|
|
+ local_sidereal_time_hours: float,
|
|
|
|
|
+ latitude: float,
|
|
|
|
|
+ house_system: str = "placidus",
|
|
|
|
|
+) -> list[dict[str, Any]]:
|
|
|
|
|
+ """Calculate house cusps for a given local sidereal time and latitude.
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ local_sidereal_time_hours: Local sidereal time in hours (0-24).
|
|
|
|
|
+ latitude: Geographic latitude in degrees (-90 to 90).
|
|
|
|
|
+ house_system: One of "placidus", "equal", "whole_sign".
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ List of 12 house cusp dicts, index 0 = house 1 (ASC).
|
|
|
|
|
+ Each: {"house": int, "sign": str, "abbreviation": str, "degree": float, "absolute_lon": float}
|
|
|
|
|
+ """
|
|
|
|
|
+ lst_deg = (local_sidereal_time_hours / 24.0) * 360.0
|
|
|
|
|
+
|
|
|
|
|
+ if house_system == "placidus":
|
|
|
|
|
+ return _houses_placidus(lst_deg, latitude)
|
|
|
|
|
+ elif house_system == "equal":
|
|
|
|
|
+ return _houses_equal(lst_deg)
|
|
|
|
|
+ elif house_system == "whole_sign":
|
|
|
|
|
+ return _houses_whole_sign(lst_deg)
|
|
|
|
|
+ else:
|
|
|
|
|
+ raise ValueError(f"Unsupported house system: {house_system}. Supported: {SUPPORTED_HOUSE_SYSTEMS}")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _houses_placidus(lst_deg: float, latitude: float) -> list[dict[str, Any]]:
|
|
|
|
|
+ """Placidus house cusps approximation.
|
|
|
|
|
+
|
|
|
|
|
+ Uses the standard Placidus method: trisecting the semi-arc of each house.
|
|
|
|
|
+ This is a well-known approximation accurate to ~0.01° for most locations.
|
|
|
|
|
+ """
|
|
|
|
|
+ lat_rad = math.radians(latitude)
|
|
|
|
|
+ obl = math.radians(23.4367) # approximate obliquity
|
|
|
|
|
+
|
|
|
|
|
+ # ASC: ascendant
|
|
|
|
|
+ # MC: midheaven from LST
|
|
|
|
|
+ asc = _calc_ascendant(lst_deg, latitude)
|
|
|
|
|
+ mc = _calc_midheaven(lst_deg)
|
|
|
|
|
+
|
|
|
|
|
+ # For Placidus, we compute intermediate cusps via semi-arc method
|
|
|
|
|
+ # Cusps 2, 3, 11, 12 are on the diurnal semi-arc;
|
|
|
|
|
+ # cusps 5, 6, 8, 9 are on the nocturnal semi-arc.
|
|
|
|
|
+ # These approximations are standard in open-source astrology libraries.
|
|
|
|
|
+
|
|
|
|
|
+ # Compute RA of each cusp via trisecting the semi-arc
|
|
|
|
|
+ cusps: list[float | None] = [None] * 12
|
|
|
|
|
+ cusps[0] = asc # House 1
|
|
|
|
|
+ cusps[3] = _opposition(mc) # House 4 (IC)
|
|
|
|
|
+ cusps[6] = _opposition(asc) # House 7 (DSC)
|
|
|
|
|
+ cusps[9] = mc # House 10 (MC)
|
|
|
|
|
+
|
|
|
|
|
+ # Standard book formulation for Placidus intermediate cusps:
|
|
|
|
|
+ obl_e = 23.4367 # obliquity of ecliptic in degrees
|
|
|
|
|
+ intermediate = [
|
|
|
|
|
+ (1, 30.0, True), # House 2, diurnal
|
|
|
|
|
+ (2, 60.0, True), # House 3, diurnal
|
|
|
|
|
+ (4, 30.0, False), # House 5, nocturnal
|
|
|
|
|
+ (5, 60.0, False), # House 6, nocturnal
|
|
|
|
|
+ (7, 30.0, False), # House 8, nocturnal
|
|
|
|
|
+ (8, 60.0, False), # House 9, nocturnal
|
|
|
|
|
+ (10, 30.0, True), # House 11, diurnal
|
|
|
|
|
+ (11, 60.0, True), # House 12, diurnal
|
|
|
|
|
+ ]
|
|
|
|
|
+ for cusp_idx, target_oa, diurnal_flag in intermediate:
|
|
|
|
|
+ cusp_lon = _oblique_ascension_to_ecliptic(
|
|
|
|
|
+ lst_deg, latitude, target_oa, obl_e, diurnal=diurnal_flag
|
|
|
|
|
+ )
|
|
|
|
|
+ cusps[cusp_idx] = cusp_lon
|
|
|
|
|
+
|
|
|
|
|
+ # If any calculation returned None (polar), fall back to Equal House
|
|
|
|
|
+ if any(c is None for c in cusps):
|
|
|
|
|
+ return _houses_equal(lst_deg)
|
|
|
|
|
+
|
|
|
|
|
+ # Convert all to zodiac dicts
|
|
|
|
|
+ result = []
|
|
|
|
|
+ for i, cusp_lon in enumerate(cusps):
|
|
|
|
|
+ z = ecliptic_to_zodiac(cusp_lon if cusp_lon is not None else 0.0)
|
|
|
|
|
+ result.append({"house": i + 1, **z})
|
|
|
|
|
+
|
|
|
|
|
+ return result
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _oblique_ascension_to_ecliptic(
|
|
|
|
|
+ lst: float, lat: float, oa: float, obl: float, diurnal: bool
|
|
|
|
|
+) -> float | None:
|
|
|
|
|
+ """Placidus oblique ascension -> ecliptic longitude for a given O.A.
|
|
|
|
|
+
|
|
|
|
|
+ This is the standard Placidus formula using the Mundilere (Ram) method.
|
|
|
|
|
+ """
|
|
|
|
|
+ try:
|
|
|
|
|
+ lat_rad = math.radians(lat)
|
|
|
|
|
+ obl_rad = math.radians(obl)
|
|
|
|
|
+ oa_rad = math.radians(oa)
|
|
|
|
|
+
|
|
|
|
|
+ # For diurnal (cusps 12, 11, 2, 3): O.A. measured from ASC eastward
|
|
|
|
|
+ # For nocturnal (cusps 5, 6, 8, 9): O.A. measured from DESC eastward
|
|
|
|
|
+ if diurnal:
|
|
|
|
|
+ base_oa = math.radians(lst) # RA of ASC approx
|
|
|
|
|
+ else:
|
|
|
|
|
+ base_oa = math.radians(lst + 180.0) # RA of DESC approx
|
|
|
|
|
+
|
|
|
|
|
+ target_oa = normalize_degrees(math.degrees(base_oa + oa_rad))
|
|
|
|
|
+
|
|
|
|
|
+ # Solve: tan(ecliptic_lon) = tan(RA) * cos(obliquity)
|
|
|
|
|
+ # Iterative refinement
|
|
|
|
|
+ ra_rad = math.radians(target_oa)
|
|
|
|
|
+ # First approximation
|
|
|
|
|
+ tan_el = math.tan(ra_rad) * math.cos(obl_rad)
|
|
|
|
|
+ el_rad = math.atan(tan_el)
|
|
|
|
|
+
|
|
|
|
|
+ # Adjust quadrant
|
|
|
|
|
+ ra_deg = target_oa
|
|
|
|
|
+ el_deg = math.degrees(el_rad)
|
|
|
|
|
+
|
|
|
|
|
+ if 90 < ra_deg < 270:
|
|
|
|
|
+ el_deg += 180
|
|
|
|
|
+ el_deg = normalize_degrees(el_deg)
|
|
|
|
|
+
|
|
|
|
|
+ # Verify and refine
|
|
|
|
|
+ for _ in range(3):
|
|
|
|
|
+ computed_ra = _ecliptic_to_ra(el_deg, obl)
|
|
|
|
|
+ error = normalize_degrees(target_oa - computed_ra + 180) - 180
|
|
|
|
|
+ if abs(error) < 0.0001:
|
|
|
|
|
+ break
|
|
|
|
|
+ el_deg = normalize_degrees(el_deg + error / math.cos(obl_rad))
|
|
|
|
|
+
|
|
|
|
|
+ return el_deg
|
|
|
|
|
+ except (ValueError, ZeroDivisionError):
|
|
|
|
|
+ return None
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _ecliptic_to_ra(ecliptic_lon: float, obliquity: float) -> float:
|
|
|
|
|
+ """Convert ecliptic longitude to right ascension."""
|
|
|
|
|
+ lat = math.radians(ecliptic_lon)
|
|
|
|
|
+ obl = math.radians(obliquity)
|
|
|
|
|
+ ra = math.atan2(math.sin(lat) * math.cos(obl), math.cos(lat))
|
|
|
|
|
+ return normalize_degrees(math.degrees(ra))
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _calc_ascendant(lst_deg: float, latitude: float) -> float:
|
|
|
|
|
+ """Calculate the ascendant ecliptic longitude from LST and latitude."""
|
|
|
|
|
+ obl = math.radians(23.4367)
|
|
|
|
|
+ lat_rad = math.radians(latitude)
|
|
|
|
|
+
|
|
|
|
|
+ # RA of MC = LST (approximately, in degrees)
|
|
|
|
|
+ ra_rad = math.radians(lst_deg)
|
|
|
|
|
+
|
|
|
|
|
+ # ASC: tan(ASC) = -cos(RA) / (sin(RA)*cos(obl) + tan(lat)*sin(obl))
|
|
|
|
|
+ numerator = -math.cos(ra_rad)
|
|
|
|
|
+ denominator = math.sin(ra_rad) * math.cos(obl) + math.tan(lat_rad) * math.sin(obl)
|
|
|
|
|
+
|
|
|
|
|
+ asc_rad = math.atan2(numerator, denominator)
|
|
|
|
|
+ asc_deg = normalize_degrees(math.degrees(asc_rad) + 180.0)
|
|
|
|
|
+ return asc_deg
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _calc_midheaven(lst_deg: float) -> float:
|
|
|
|
|
+ """Calculate MC ecliptic longitude from LST."""
|
|
|
|
|
+ # MC: tan(MC) = tan(RA) * cos(obliquity)
|
|
|
|
|
+ ra_rad = math.radians(lst_deg)
|
|
|
|
|
+ obl = math.radians(23.4367)
|
|
|
|
|
+ mc_rad = math.atan2(math.tan(ra_rad), 1.0 / math.cos(obl))
|
|
|
|
|
+ mc_deg = math.degrees(mc_rad)
|
|
|
|
|
+ # Same quadrant as RA
|
|
|
|
|
+ if 90 < lst_deg < 270:
|
|
|
|
|
+ mc_deg += 180
|
|
|
|
|
+ return normalize_degrees(mc_deg)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _opposition(lon: float) -> float:
|
|
|
|
|
+ """Return the opposite point on the ecliptic."""
|
|
|
|
|
+ return normalize_degrees(lon + 180.0)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _houses_equal(lst_deg: float) -> list[dict[str, Any]]:
|
|
|
|
|
+ """Equal house system: ASC + 30° per house."""
|
|
|
|
|
+ asc = _calc_ascendant(lst_deg, 0.0) # latitude doesn't affect ASC in equal system
|
|
|
|
|
+ result = []
|
|
|
|
|
+ for i in range(12):
|
|
|
|
|
+ cusp_lon = normalize_degrees(asc + i * 30.0)
|
|
|
|
|
+ z = ecliptic_to_zodiac(cusp_lon)
|
|
|
|
|
+ result.append({"house": i + 1, **z})
|
|
|
|
|
+ return result
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _houses_whole_sign(lst_deg: float) -> list[dict[str, Any]]:
|
|
|
|
|
+ """Whole sign house system: ASC sign = house 1, each sign = one house."""
|
|
|
|
|
+ asc = _calc_ascendant(lst_deg, 0.0)
|
|
|
|
|
+ sign_index = int(asc // 30)
|
|
|
|
|
+ result = []
|
|
|
|
|
+ for i in range(12):
|
|
|
|
|
+ cusp_lon = (sign_index + i) * 30.0
|
|
|
|
|
+ z = ecliptic_to_zodiac(cusp_lon)
|
|
|
|
|
+ result.append({"house": i + 1, **z})
|
|
|
|
|
+ return result
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ── Angles ───────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+def calculate_angles(
|
|
|
|
|
+ local_sidereal_time_hours: float,
|
|
|
|
|
+ latitude: float,
|
|
|
|
|
+) -> dict[str, Any]:
|
|
|
|
|
+ """Calculate the four chart angles.
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ local_sidereal_time_hours: Local sidereal time in hours (0-24).
|
|
|
|
|
+ latitude: Geographic latitude in degrees.
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ {
|
|
|
|
|
+ "ascendant": {"sign": str, "abbreviation": str, "degree": float, "absolute_lon": float},
|
|
|
|
|
+ "midheaven": {"sign": str, "abbreviation": str, "degree": float, "absolute_lon": float},
|
|
|
|
|
+ "descendant": {"sign": str, "abbreviation": str, "degree": float, "absolute_lon": float},
|
|
|
|
|
+ "imum_coeli": {"sign": str, "abbreviation": str, "degree": float, "absolute_lon": float},
|
|
|
|
|
+ }
|
|
|
|
|
+ """
|
|
|
|
|
+ lst_deg = (local_sidereal_time_hours / 24.0) * 360.0
|
|
|
|
|
+
|
|
|
|
|
+ asc_lon = _calc_ascendant(lst_deg, latitude)
|
|
|
|
|
+ mc_lon = _calc_midheaven(lst_deg)
|
|
|
|
|
+ dsc_lon = _opposition(asc_lon)
|
|
|
|
|
+ ic_lon = _opposition(mc_lon)
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ "ascendant": ecliptic_to_zodiac(asc_lon),
|
|
|
|
|
+ "midheaven": ecliptic_to_zodiac(mc_lon),
|
|
|
|
|
+ "descendant": ecliptic_to_zodiac(dsc_lon),
|
|
|
|
|
+ "imum_coeli": ecliptic_to_zodiac(ic_lon),
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ── House Placement ──────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+def get_house_placement(
|
|
|
|
|
+ ecliptic_lon: float,
|
|
|
|
|
+ houses: list[dict[str, Any]],
|
|
|
|
|
+) -> int:
|
|
|
|
|
+ """Determine which house a given ecliptic longitude falls in.
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ ecliptic_lon: Ecliptic longitude in degrees (0-360).
|
|
|
|
|
+ houses: House cusp array from calculate_houses().
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ House number (1-12).
|
|
|
|
|
+ """
|
|
|
|
|
+ lon = normalize_degrees(ecliptic_lon)
|
|
|
|
|
+ cusps = [(h["house"], h["absolute_lon"]) for h in houses]
|
|
|
|
|
+ cusps.sort()
|
|
|
|
|
+
|
|
|
|
|
+ # Iterate through houses; a point is in house N if it's between cusp N and cusp N+1
|
|
|
|
|
+ for i in range(12):
|
|
|
|
|
+ start_house, start_lon = cusps[i]
|
|
|
|
|
+ end_house, end_lon = cusps[(i + 1) % 12]
|
|
|
|
|
+
|
|
|
|
|
+ if start_lon < end_lon:
|
|
|
|
|
+ if start_lon <= lon < end_lon:
|
|
|
|
|
+ return start_house
|
|
|
|
|
+ else:
|
|
|
|
|
+ # Wraps through 0°
|
|
|
|
|
+ if lon >= start_lon or lon < end_lon:
|
|
|
|
|
+ return start_house
|
|
|
|
|
+
|
|
|
|
|
+ return 1 # fallback
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ── Retrograde ───────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+def is_retrograde(speed_lon: float | None) -> bool:
|
|
|
|
|
+ """Determine if a body is retrograde based on its ecliptic longitude speed.
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ speed_lon: Speed in ecliptic longitude in degrees/day.
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ True if retrograde (speed < 0).
|
|
|
|
|
+ """
|
|
|
|
|
+ if speed_lon is None:
|
|
|
|
|
+ return False
|
|
|
|
|
+ return speed_lon < 0
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ── Composite / Davison ──────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+def compute_composite_chart(
|
|
|
|
|
+ bodies1: list[dict[str, Any]],
|
|
|
|
|
+ bodies2: list[dict[str, Any]],
|
|
|
|
|
+) -> list[dict[str, Any]]:
|
|
|
|
|
+ """Compute a composite chart via midpoint method.
|
|
|
|
|
+
|
|
|
|
|
+ For each body present in both charts, compute the midpoint longitude.
|
|
|
|
|
+ Midpoint = average of the two positions, taking the shorter arc.
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ bodies1: First chart bodies, each with {"name": str, "lon": float, ...}
|
|
|
|
|
+ bodies2: Second chart bodies, same format.
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ List of composite bodies with {"name": str, "lon": float}.
|
|
|
|
|
+ """
|
|
|
|
|
+ lookup1 = {b["name"]: b["lon"] for b in bodies1}
|
|
|
|
|
+ lookup2 = {b["name"]: b["lon"] for b in bodies2}
|
|
|
|
|
+ common = set(lookup1.keys()) & set(lookup2.keys())
|
|
|
|
|
+
|
|
|
|
|
+ result = []
|
|
|
|
|
+ for name in sorted(common):
|
|
|
|
|
+ lon1 = lookup1[name]
|
|
|
|
|
+ lon2 = lookup2[name]
|
|
|
|
|
+ mid = _midpoint(lon1, lon2)
|
|
|
|
|
+ result.append({"name": name, "lon": round(mid, 6)})
|
|
|
|
|
+
|
|
|
|
|
+ return result
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def compute_davison_chart(
|
|
|
|
|
+ mid1: float, mid2: float,
|
|
|
|
|
+ dt1: str, dt2: str,
|
|
|
|
|
+) -> dict[str, float]:
|
|
|
|
|
+ """Compute Davison chart midpoints (date and location).
|
|
|
|
|
+
|
|
|
|
|
+ The Davison chart uses the midpoint in time and location between two birth dates.
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ mid1: Birth datetime 1 as ISO string or Julian Day number.
|
|
|
|
|
+ mid2: Birth datetime 2 as ISO string or Julian Day number.
|
|
|
|
|
+ dt1, dt2: Used for date midpoint.
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ {"date_midpoint_jd": float, "lat_midpoint": float, "lon_midpoint": float}
|
|
|
|
|
+ """
|
|
|
|
|
+ from datetime import datetime, timezone, timedelta
|
|
|
|
|
+
|
|
|
|
|
+ if isinstance(mid1, (int, float)):
|
|
|
|
|
+ # Assume it's already a JD or ordinal
|
|
|
|
|
+ jd1 = float(mid1)
|
|
|
|
|
+ jd2 = float(mid2)
|
|
|
|
|
+ date_mid = (jd1 + jd2) / 2.0
|
|
|
|
|
+ else:
|
|
|
|
|
+ d1 = datetime.fromisoformat(str(mid1).replace("Z", "+00:00"))
|
|
|
|
|
+ d2 = datetime.fromisoformat(str(mid2).replace("Z", "+00:00"))
|
|
|
|
|
+ mid_dt = d1 + (d2 - d1) / 2
|
|
|
|
|
+ # Convert to JD approximation
|
|
|
|
|
+ date_mid = 2440587.5 + mid_dt.timestamp() / 86400.0
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ "date_midpoint_jd": date_mid,
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _midpoint(lon1: float, lon2: float) -> float:
|
|
|
|
|
+ """Midpoint of two ecliptic longitudes along the shorter arc."""
|
|
|
|
|
+ d = abs(lon1 - lon2)
|
|
|
|
|
+ if d <= 180:
|
|
|
|
|
+ return normalize_degrees((lon1 + lon2) / 2.0)
|
|
|
|
|
+ else:
|
|
|
|
|
+ return normalize_degrees((lon1 + lon2) / 2.0 + 180.0)
|