259 lines
9.8 KiB
Python
259 lines
9.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Native, auditable tropical Western natal-chart calculation.
|
|
|
|
This module deliberately uses the project's existing Swiss Ephemeris binding
|
|
instead of bundling an AGPL Western astrology library. It is a calculation
|
|
layer only: transits, progressions, solar arcs, returns, and interpretation
|
|
remain separate evidence layers.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
from datetime import datetime, timedelta, timezone as fixed_timezone
|
|
from typing import Any
|
|
from zoneinfo import ZoneInfo
|
|
|
|
import swisseph as swe
|
|
|
|
try:
|
|
from western_evidence_packet import build_western_evidence_packet
|
|
except ImportError: # pragma: no cover - package import path
|
|
from scripts.western_evidence_packet import build_western_evidence_packet
|
|
|
|
|
|
_PLANETS = {
|
|
"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,
|
|
"true_node": swe.TRUE_NODE,
|
|
}
|
|
_SIGNS = (
|
|
"Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
|
|
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
|
|
)
|
|
_ELEMENTS = {
|
|
"Aries": "fire", "Leo": "fire", "Sagittarius": "fire",
|
|
"Taurus": "earth", "Virgo": "earth", "Capricorn": "earth",
|
|
"Gemini": "air", "Libra": "air", "Aquarius": "air",
|
|
"Cancer": "water", "Scorpio": "water", "Pisces": "water",
|
|
}
|
|
_MODES = {
|
|
"Aries": "cardinal", "Cancer": "cardinal", "Libra": "cardinal", "Capricorn": "cardinal",
|
|
"Taurus": "fixed", "Leo": "fixed", "Scorpio": "fixed", "Aquarius": "fixed",
|
|
"Gemini": "mutable", "Virgo": "mutable", "Sagittarius": "mutable", "Pisces": "mutable",
|
|
}
|
|
_RULERS = {
|
|
"Aries": "mars", "Taurus": "venus", "Gemini": "mercury", "Cancer": "moon",
|
|
"Leo": "sun", "Virgo": "mercury", "Libra": "venus", "Scorpio": "mars",
|
|
"Sagittarius": "jupiter", "Capricorn": "saturn", "Aquarius": "saturn", "Pisces": "jupiter",
|
|
}
|
|
_ASPECTS = {"conjunction": 0.0, "sextile": 60.0, "square": 90.0, "trine": 120.0, "opposition": 180.0}
|
|
_ORB = {"sun": 8.0, "moon": 8.0, "ascendant": 5.0, "mc": 5.0}
|
|
|
|
|
|
def _longitude(value: float) -> float:
|
|
return float(value) % 360.0
|
|
|
|
|
|
def _point(longitude: float, *, house: int | None = None, speed: float | None = None) -> dict[str, Any]:
|
|
longitude = _longitude(longitude)
|
|
point = {
|
|
"longitude": round(longitude, 6),
|
|
"sign": _SIGNS[int(longitude // 30)],
|
|
"degree_in_sign": round(longitude % 30, 6),
|
|
}
|
|
if house is not None:
|
|
point["house"] = house
|
|
if speed is not None:
|
|
point["speed_longitude"] = round(float(speed), 8)
|
|
point["retrograde"] = bool(speed < 0)
|
|
return point
|
|
|
|
|
|
def _house_for_longitude(longitude: float, cusps: list[float]) -> int:
|
|
"""Return Placidus house by testing each cusp-to-next-cusp circular arc."""
|
|
longitude = _longitude(longitude)
|
|
for index, cusp in enumerate(cusps):
|
|
start = _longitude(cusp)
|
|
end = _longitude(cusps[(index + 1) % 12])
|
|
span = (end - start) % 360.0
|
|
if (longitude - start) % 360.0 < span:
|
|
return index + 1
|
|
raise RuntimeError("Unable to assign longitude to a house") # pragma: no cover
|
|
|
|
|
|
def _orb_for(left: str, right: str) -> float:
|
|
return min(_ORB.get(left, 6.0), _ORB.get(right, 6.0))
|
|
|
|
|
|
def _aspects(points: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
|
|
names = list(points)
|
|
found: list[dict[str, Any]] = []
|
|
for index, left in enumerate(names):
|
|
for right in names[index + 1:]:
|
|
separation = abs(points[left]["longitude"] - points[right]["longitude"])
|
|
separation = min(separation, 360.0 - separation)
|
|
allowed_orb = _orb_for(left, right)
|
|
for aspect, exact in _ASPECTS.items():
|
|
orb = abs(separation - exact)
|
|
if orb <= allowed_orb:
|
|
found.append({
|
|
"left": left,
|
|
"right": right,
|
|
"aspect": aspect,
|
|
"exact_degrees": exact,
|
|
"separation": round(separation, 6),
|
|
"orb": round(orb, 6),
|
|
"allowed_orb": allowed_orb,
|
|
})
|
|
return sorted(found, key=lambda row: (row["orb"], row["left"], row["right"]))
|
|
|
|
|
|
def _distribution(planets: dict[str, dict[str, Any]]) -> dict[str, dict[str, int]]:
|
|
elements = {name: 0 for name in ("fire", "earth", "air", "water")}
|
|
modes = {name: 0 for name in ("cardinal", "fixed", "mutable")}
|
|
for planet in planets.values():
|
|
elements[_ELEMENTS[planet["sign"]]] += 1
|
|
modes[_MODES[planet["sign"]]] += 1
|
|
return {"elements": elements, "modes": modes}
|
|
|
|
|
|
def _ruler_chains(cusps: list[float], planets: dict[str, dict[str, Any]]) -> dict[str, list[str]]:
|
|
chains: dict[str, list[str]] = {}
|
|
for house, cusp in enumerate(cusps, start=1):
|
|
sign = _SIGNS[int(_longitude(cusp) // 30)]
|
|
chain: list[str] = []
|
|
current = _RULERS[sign]
|
|
for _ in range(12):
|
|
if current in chain:
|
|
break
|
|
chain.append(current)
|
|
current = _RULERS[planets[current]["sign"]]
|
|
chains[str(house)] = chain
|
|
return chains
|
|
|
|
|
|
def _birth_zone(value: str | float | int):
|
|
if isinstance(value, str):
|
|
return ZoneInfo(value), value
|
|
offset = float(value)
|
|
return fixed_timezone(timedelta(hours=offset)), f"UTC{offset:+g}"
|
|
|
|
|
|
def build_tropical_natal_chart(
|
|
*,
|
|
year: int,
|
|
month: int,
|
|
day: int,
|
|
hour: int,
|
|
minute: int,
|
|
latitude: float,
|
|
longitude: float,
|
|
timezone: str | float | int,
|
|
second: int = 0,
|
|
house_system: str = "P",
|
|
) -> dict[str, Any]:
|
|
"""Calculate a tropical natal chart from local birth data using Swiss Ephemeris."""
|
|
if len(house_system) != 1:
|
|
raise ValueError("house_system must be a single Swiss Ephemeris house-system letter")
|
|
zone, timezone_label = _birth_zone(timezone)
|
|
local = datetime(year, month, day, hour, minute, second, tzinfo=zone)
|
|
utc = local.astimezone(ZoneInfo("UTC"))
|
|
jd_ut = swe.julday(utc.year, utc.month, utc.day, utc.hour + utc.minute / 60 + utc.second / 3600)
|
|
flags = swe.FLG_SWIEPH | swe.FLG_SPEED
|
|
cusps_raw, ascmc = swe.houses_ex(jd_ut, float(latitude), float(longitude), house_system.encode("ascii"), 0)
|
|
cusps = [_longitude(cusp) for cusp in cusps_raw]
|
|
planets: dict[str, dict[str, Any]] = {}
|
|
for name, planet_id in _PLANETS.items():
|
|
values, _ = swe.calc_ut(jd_ut, planet_id, flags)
|
|
position = _point(values[0], house=_house_for_longitude(values[0], cusps), speed=values[3])
|
|
planets[name] = position
|
|
angles = {
|
|
"ascendant": _point(ascmc[0]),
|
|
"mc": _point(ascmc[1]),
|
|
"descendant": _point(ascmc[0] + 180.0),
|
|
"ic": _point(ascmc[1] + 180.0),
|
|
}
|
|
aspect_points = {**planets, "ascendant": angles["ascendant"], "mc": angles["mc"]}
|
|
natal = {
|
|
"ascendant": angles["ascendant"],
|
|
"mc": angles["mc"],
|
|
"angles": angles,
|
|
"planets": planets,
|
|
"houses": [{"house": index + 1, "cusp": _point(cusp)} for index, cusp in enumerate(cusps)],
|
|
"aspects": _aspects(aspect_points),
|
|
"distribution": _distribution(planets),
|
|
"house_ruler_chains": _ruler_chains(cusps, planets),
|
|
}
|
|
return {
|
|
"source_engine": "pyswisseph_tropical",
|
|
"engine_version": getattr(swe, "version", "unknown"),
|
|
"zodiac": "tropical",
|
|
"house_system": house_system.upper(),
|
|
"calculation_contract": {
|
|
"birth_timezone": timezone_label,
|
|
"local_birth_time": local.isoformat(),
|
|
"utc_birth_time": utc.isoformat(),
|
|
"julian_day_ut": round(jd_ut, 8),
|
|
"latitude": float(latitude),
|
|
"longitude": float(longitude),
|
|
"ephemeris": "Swiss Ephemeris via pyswisseph",
|
|
},
|
|
"natal": natal,
|
|
"boundary": "Natal tropical calculation only; it does not calculate transits, progressions, solar arcs, returns, or interpretation.",
|
|
}
|
|
|
|
|
|
def build_tropical_western_evidence_packet(*, route_packet: dict[str, Any], **birth: Any) -> dict[str, Any]:
|
|
"""Wrap direct natal calculation in the existing cross-system packet contract."""
|
|
chart = build_tropical_natal_chart(**birth)
|
|
packet = build_western_evidence_packet(
|
|
route_packet=route_packet,
|
|
natal=chart["natal"],
|
|
timing_techniques={},
|
|
signals=[],
|
|
)
|
|
packet.update({
|
|
"source_engine": chart["source_engine"],
|
|
"calculation": {
|
|
"status": "used",
|
|
"source_engine": chart["source_engine"],
|
|
"zodiac": chart["zodiac"],
|
|
"house_system": chart["house_system"],
|
|
"contract": chart["calculation_contract"],
|
|
},
|
|
"native_chart": chart,
|
|
"boundary": chart["boundary"],
|
|
})
|
|
return packet
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description="Calculate an auditable tropical Western natal chart.")
|
|
for name, kind in (("year", int), ("month", int), ("day", int), ("hour", int), ("minute", int)):
|
|
parser.add_argument(f"--{name}", required=True, type=kind)
|
|
parser.add_argument("--lat", required=True, type=float)
|
|
parser.add_argument("--lon", required=True, type=float)
|
|
parser.add_argument("--timezone", required=True)
|
|
parser.add_argument("--second", type=int, default=0)
|
|
parser.add_argument("--house-system", default="P")
|
|
args = parser.parse_args()
|
|
print(json.dumps(build_tropical_natal_chart(
|
|
year=args.year, month=args.month, day=args.day, hour=args.hour, minute=args.minute, second=args.second,
|
|
latitude=args.lat, longitude=args.lon, timezone=args.timezone, house_system=args.house_system,
|
|
), ensure_ascii=False, indent=2))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__": # pragma: no cover
|
|
raise SystemExit(main())
|