|
|
@@ -319,54 +319,100 @@ def calculate_houses(
|
|
|
|
|
|
|
|
|
def _houses_placidus(lst_deg: float, latitude: float) -> list[dict[str, Any]]:
|
|
|
- """Placidus house cusps approximation.
|
|
|
+ """Placidus house cusps.
|
|
|
|
|
|
- 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.
|
|
|
+ Standard Placidus semi-arc trisection method.
|
|
|
+ Houses numbered counter-clockwise from ASC (house 1).
|
|
|
+
|
|
|
+ The algorithm:
|
|
|
+ 1. Compute RA of ASC, MC, DSC, IC
|
|
|
+ 2. The diurnal semi-arc (ASC→DSC eastward through MC) = ~180° in RA
|
|
|
+ 3. The nocturnal semi-arc (DSC→ASC eastward through IC) = ~180° in RA
|
|
|
+ 4. Trisect each semi-arc to get intermediate cusps
|
|
|
+ 5. Convert RA cusps back to ecliptic longitude
|
|
|
"""
|
|
|
- lat_rad = math.radians(latitude)
|
|
|
- obl = math.radians(23.4367) # approximate obliquity
|
|
|
+ obl_e = 23.4367
|
|
|
|
|
|
- # 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.
|
|
|
+ mc = _calc_midheaven(lst_deg)
|
|
|
+ dsc = _opposition(asc)
|
|
|
+ ic = _opposition(mc)
|
|
|
+
|
|
|
+ ra_asc = _ecliptic_to_ra(asc, obl_e)
|
|
|
+ ra_mc = _ecliptic_to_ra(mc, obl_e)
|
|
|
+ ra_dsc = _ecliptic_to_ra(dsc, obl_e)
|
|
|
+ ra_ic = _ecliptic_to_ra(ic, obl_e)
|
|
|
+
|
|
|
+ # Helper: trisect an arc in RA space
|
|
|
+ def _trisect_ra(ra_start, ra_end, fraction):
|
|
|
+ """Move fraction of the way from ra_start to ra_end.
|
|
|
+ ra_end should already be adjusted so the arc goes in the correct direction
|
|
|
+ (i.e., ra_end > ra_start for the desired direction, possibly with +360 offset)."""
|
|
|
+ arc = ra_end - ra_start # may be > 180 or < 0, that's OK
|
|
|
+ if arc < 0:
|
|
|
+ arc += 360
|
|
|
+ if arc > 180:
|
|
|
+ # This shouldn't happen if direction is correct, but handle it
|
|
|
+ arc = arc - 360
|
|
|
+ target = ra_start + arc * fraction
|
|
|
+ return _ra_to_ecliptic(normalize_degrees(target), obl_e)
|
|
|
+
|
|
|
+ # Determine the correct direction for semi-arc trisection.
|
|
|
+ # The diurnal semi-arc (ASC → MC → DSC) should contain MC between ASC and DSC.
|
|
|
+ # Check if MC is between ASC and DSC going eastward.
|
|
|
+ arc_mc_from_asc = (ra_mc - ra_asc) % 360
|
|
|
+ if arc_mc_from_asc > 180:
|
|
|
+ # MC is NOT between ASC and DSC going eastward.
|
|
|
+ # Go westward instead (negative RA offset = westward).
|
|
|
+ direction = -1
|
|
|
+ # Adjust: add 360 to MC and DSC RAs so they're "before" ASC in westward direction
|
|
|
+ ra_mc_eff = ra_mc + 360
|
|
|
+ ra_dsc_eff = ra_dsc + 360
|
|
|
+ ra_ic_eff = ra_ic + 360
|
|
|
+ else:
|
|
|
+ direction = 1
|
|
|
+ ra_mc_eff = ra_mc
|
|
|
+ ra_dsc_eff = ra_dsc
|
|
|
+ ra_ic_eff = ra_ic
|
|
|
|
|
|
- # 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
|
|
|
+ cusps[0] = asc # H1: ASC
|
|
|
+ cusps[3] = ic # H4: IC
|
|
|
+ cusps[6] = dsc # H7: DSC
|
|
|
+ cusps[9] = mc # H10: MC
|
|
|
+
|
|
|
+ # Diurnal semi-arc: ASC → MC → DSC
|
|
|
+ # H12 = 1/3 from ASC to MC, H11 = 2/3 from ASC to MC
|
|
|
+ # H9 = 1/3 from MC to DSC, H8 = 2/3 from MC to DSC
|
|
|
+ if direction == 1:
|
|
|
+ cusps[11] = _trisect_ra(ra_asc, ra_mc, 1.0/3.0)
|
|
|
+ cusps[10] = _trisect_ra(ra_asc, ra_mc, 2.0/3.0)
|
|
|
+ cusps[8] = _trisect_ra(ra_mc, ra_dsc, 1.0/3.0)
|
|
|
+ cusps[7] = _trisect_ra(ra_mc, ra_dsc, 2.0/3.0)
|
|
|
+ # Nocturnal: DSC → IC → ASC
|
|
|
+ cusps[5] = _trisect_ra(ra_dsc, ra_ic, 1.0/3.0)
|
|
|
+ cusps[4] = _trisect_ra(ra_dsc, ra_ic, 2.0/3.0)
|
|
|
+ cusps[2] = _trisect_ra(ra_ic, ra_asc, 1.0/3.0)
|
|
|
+ cusps[1] = _trisect_ra(ra_ic, ra_asc, 2.0/3.0)
|
|
|
+ else:
|
|
|
+ # Westward: use adjusted RAs
|
|
|
+ # Diurnal: ASC → MC → DSC (westward = decreasing RA, but we use +360 adjusted values)
|
|
|
+ cusps[11] = _trisect_ra(ra_asc, ra_mc_eff, 1.0/3.0)
|
|
|
+ cusps[10] = _trisect_ra(ra_asc, ra_mc_eff, 2.0/3.0)
|
|
|
+ cusps[8] = _trisect_ra(ra_mc_eff, ra_dsc_eff, 1.0/3.0)
|
|
|
+ cusps[7] = _trisect_ra(ra_mc_eff, ra_dsc_eff, 2.0/3.0)
|
|
|
+ # Nocturnal: DSC → IC → ASC (westward)
|
|
|
+ # Going westward from DSC: DSC → IC → ASC
|
|
|
+ # But with +360 adjustment: DSC+360 → IC+360 → ASC+360
|
|
|
+ ra_asc_eff = ra_asc + 360
|
|
|
+ cusps[5] = _trisect_ra(ra_dsc_eff, ra_ic_eff, 1.0/3.0)
|
|
|
+ cusps[4] = _trisect_ra(ra_dsc_eff, ra_ic_eff, 2.0/3.0)
|
|
|
+ cusps[2] = _trisect_ra(ra_ic_eff, ra_asc_eff, 1.0/3.0)
|
|
|
+ cusps[1] = _trisect_ra(ra_ic_eff, ra_asc_eff, 2.0/3.0)
|
|
|
|
|
|
- # 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)
|
|
|
@@ -432,6 +478,36 @@ def _ecliptic_to_ra(ecliptic_lon: float, obliquity: float) -> float:
|
|
|
return normalize_degrees(math.degrees(ra))
|
|
|
|
|
|
|
|
|
+def _ra_to_ecliptic(ra_deg: float, obliquity: float) -> float:
|
|
|
+ """Convert right ascension to ecliptic longitude.
|
|
|
+
|
|
|
+ Inverse of _ecliptic_to_ra. Uses iterative refinement for accuracy.
|
|
|
+ """
|
|
|
+ obl_rad = math.radians(obliquity)
|
|
|
+ ra_rad = math.radians(ra_deg)
|
|
|
+
|
|
|
+ # First approximation: tan(el) = tan(ra) / cos(obliquity)
|
|
|
+ tan_el = math.tan(ra_rad) / math.cos(obl_rad)
|
|
|
+ el_rad = math.atan(tan_el)
|
|
|
+ el_deg = math.degrees(el_rad)
|
|
|
+
|
|
|
+ # Adjust quadrant: ecliptic longitude should be in the same
|
|
|
+ # half-circle as the RA.
|
|
|
+ if 90 < ra_deg < 270:
|
|
|
+ el_deg += 180
|
|
|
+ el_deg = normalize_degrees(el_deg)
|
|
|
+
|
|
|
+ # Refine iteratively
|
|
|
+ for _ in range(5):
|
|
|
+ computed_ra = _ecliptic_to_ra(el_deg, obliquity)
|
|
|
+ error = normalize_degrees(ra_deg - computed_ra + 180) - 180
|
|
|
+ if abs(error) < 0.0001:
|
|
|
+ break
|
|
|
+ el_deg = normalize_degrees(el_deg + error * math.cos(obl_rad))
|
|
|
+
|
|
|
+ return el_deg
|
|
|
+
|
|
|
+
|
|
|
def _calc_ascendant(lst_deg: float, latitude: float) -> float:
|
|
|
"""Calculate the ascendant ecliptic longitude from LST and latitude."""
|
|
|
obl = math.radians(23.4367)
|
|
|
@@ -451,10 +527,10 @@ def _calc_ascendant(lst_deg: float, latitude: float) -> float:
|
|
|
|
|
|
def _calc_midheaven(lst_deg: float) -> float:
|
|
|
"""Calculate MC ecliptic longitude from LST."""
|
|
|
- # MC: tan(MC) = tan(RA) * cos(obliquity)
|
|
|
+ # 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_rad = math.atan2(math.tan(ra_rad), math.cos(obl))
|
|
|
mc_deg = math.degrees(mc_rad)
|
|
|
# Same quadrant as RA
|
|
|
if 90 < lst_deg < 270:
|