Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
254 lines
11 KiB
Python
254 lines
11 KiB
Python
"""Angle-based timing features for birth-time rectification research (BUG-1141).
|
|
|
|
Offline only. For each public v5 case and each candidate birth minute in a
|
|
±60-minute window, measure how close three degree-level timing techniques come
|
|
to an exact contact at each dated life event:
|
|
|
|
TR transiting Saturn / Jupiter / mean Rahu (and Mars for day-precision
|
|
events) to the natal Ascendant or Midheaven, 0° / 90° / 180°;
|
|
SP secondary-progressed (day-for-a-year) Ascendant / Midheaven to natal
|
|
Sun..Saturn, 0° / 90° / 180°;
|
|
SA solar-arc-directed Ascendant / Midheaven to natal Sun..Saturn,
|
|
0° / 90° / 180°.
|
|
|
|
Aspects between points in one zodiac do not depend on the zodiac, so everything
|
|
is tropical. Event precision: day → that date; month → TR takes the closest
|
|
contact over the month (daily samples), SP / SA the mid-month point (progressed
|
|
angles move ~0.08° a month); year → SP / SA only, closest contact over the year
|
|
(quarterly samples). TR skips year events (Saturn moves ~12° a year, far wider
|
|
than any orb tested). Progressed angles are exact every 5 candidate minutes and
|
|
linearly interpolated in between (error well under 0.05°).
|
|
|
|
A candidate's feature for one event is the minimum separation (degrees) over
|
|
its targets and aspects; the research compares the truth minute's count of
|
|
events within an orb against shuffled-date controls.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import calendar
|
|
import hashlib
|
|
import json
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from datetime import date, datetime, timedelta
|
|
from pathlib import Path
|
|
from random import Random
|
|
from typing import Any, Iterable, Sequence
|
|
|
|
import numpy as np
|
|
import swisseph as swe
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
if str(ROOT) not in sys.path:
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
HOLDOUT_V5 = ROOT / "references" / "real_case_calibration" / "minute_rectification_holdout_v5.json"
|
|
EVENT_CUTOFF = date(2026, 9, 14)
|
|
RADIUS_MAX = 60
|
|
ASPECTS = (0.0, 90.0, 180.0)
|
|
NATAL_TARGETS = (swe.SUN, swe.MOON, swe.MERCURY, swe.VENUS, swe.MARS, swe.JUPITER, swe.SATURN)
|
|
TRANSIT_SLOW = (swe.SATURN, swe.JUPITER, swe.MEAN_NODE)
|
|
TRANSIT_DAY_EXTRA = (swe.MARS,)
|
|
TECHNIQUES = ("TR", "SP", "SA")
|
|
ORBS = (1.0, 2.0, 3.0)
|
|
TROPICAL_YEAR = 365.24219
|
|
FLAGS = swe.FLG_SWIEPH
|
|
|
|
|
|
def load_cases(path: Path = HOLDOUT_V5) -> list[dict[str, Any]]:
|
|
return json.loads(path.read_text(encoding="utf-8"))["cases"]
|
|
|
|
|
|
def julian_ut(moment_local: datetime, tz_hours: float) -> float:
|
|
utc = moment_local - timedelta(hours=float(tz_hours))
|
|
return swe.julday(utc.year, utc.month, utc.day, utc.hour + utc.minute / 60 + utc.second / 3600)
|
|
|
|
|
|
def birth_local(case: dict[str, Any]) -> datetime:
|
|
birth = case["birth"]
|
|
return datetime.strptime(f"{birth['date']} {str(birth['time'])[:5]}", "%Y-%m-%d %H:%M")
|
|
|
|
|
|
def angle_diff(a: np.ndarray | float, b: np.ndarray | float) -> np.ndarray | float:
|
|
"""Smallest absolute difference between longitudes in degrees (0..180)."""
|
|
d = np.abs((np.asarray(a) - np.asarray(b) + 180.0) % 360.0 - 180.0)
|
|
return d
|
|
|
|
|
|
def aspect_sep(a: np.ndarray, b: np.ndarray) -> np.ndarray:
|
|
"""Min over 0/90/180 of |sep - aspect| (degrees)."""
|
|
raw = angle_diff(a, b)
|
|
return np.minimum.reduce([np.abs(raw - aspect) for aspect in ASPECTS])
|
|
|
|
|
|
def planet_lon(jd_ut: float, body: int) -> float:
|
|
return float(swe.calc_ut(jd_ut, body, FLAGS)[0][0])
|
|
|
|
|
|
def angles(jd_ut: float, lat: float, lon: float) -> tuple[float, float]:
|
|
_cusps, ascmc = swe.houses(jd_ut, float(lat), float(lon), b"P")
|
|
return float(ascmc[0]), float(ascmc[1])
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Event:
|
|
id: str
|
|
domain: str
|
|
precision: str # day | month | year
|
|
start: date
|
|
end: date
|
|
|
|
|
|
def parse_event(raw: dict[str, Any]) -> Event | None:
|
|
text = str(raw.get("date") or "")
|
|
precision = str(raw.get("precision") or "")
|
|
try:
|
|
if precision == "day" and len(text) >= 10:
|
|
day = date.fromisoformat(text[:10]); start = end = day
|
|
elif precision == "month" and len(text) >= 7:
|
|
y, m = int(text[:4]), int(text[5:7]); start = date(y, m, 1); end = date(y, m, calendar.monthrange(y, m)[1])
|
|
elif precision == "year" and len(text) >= 4:
|
|
y = int(text[:4]); start = date(y, 1, 1); end = date(y, 12, 31)
|
|
else:
|
|
return None
|
|
except ValueError:
|
|
return None
|
|
if start > EVENT_CUTOFF:
|
|
return None
|
|
return Event(str(raw.get("id") or text), str(raw.get("domain") or ""), precision, start, min(end, EVENT_CUTOFF))
|
|
|
|
|
|
def sample_dates(event: Event, technique: str) -> list[date]:
|
|
if event.precision == "day":
|
|
return [event.start]
|
|
if event.precision == "month":
|
|
if technique == "TR":
|
|
return [event.start + timedelta(days=k) for k in range((event.end - event.start).days + 1)]
|
|
# progressed / directed angles move ~0.08° a month: the mid-month point is enough
|
|
return [event.start + timedelta(days=14)]
|
|
# year: progressed angles move ~1°/yr, quarterly samples bound the error at ~0.125°
|
|
if technique == "TR":
|
|
return []
|
|
return [event.start + timedelta(days=k) for k in (0, 91, 182, 273, (event.end - event.start).days)]
|
|
|
|
|
|
class CaseChart:
|
|
"""Natal quantities for every candidate minute of one case (offsets -60..+60)."""
|
|
|
|
def __init__(self, case: dict[str, Any]):
|
|
self.case = case
|
|
self.case_id = str(case["case_id"])
|
|
birth = case["birth"]
|
|
self.lat, self.lon, self.tz = float(birth["latitude"]), float(birth["longitude"]), float(birth["timezone_offset"])
|
|
self.local = birth_local(case)
|
|
self.offsets = np.arange(-RADIUS_MAX, RADIUS_MAX + 1)
|
|
self.jd = np.array([julian_ut(self.local + timedelta(minutes=int(o)), self.tz) for o in self.offsets])
|
|
ang = [angles(j, self.lat, self.lon) for j in self.jd]
|
|
self.asc = np.array([a for a, _ in ang]); self.mc = np.array([m for _, m in ang])
|
|
self.natal = np.array([[planet_lon(j, body) for body in NATAL_TARGETS] for j in self.jd]) # (n, 7)
|
|
self.sun = self.natal[:, 0]
|
|
|
|
def events(self, raw_events: Iterable[dict[str, Any]] | None = None) -> list[Event]:
|
|
items = [parse_event(e) for e in (raw_events if raw_events is not None else self.case["events"])]
|
|
birth_day = self.local.date()
|
|
return [e for e in items if e is not None and e.start > birth_day]
|
|
|
|
# --- per-technique minimum separation for one event, one value per candidate ----------
|
|
def sep_TR(self, event: Event) -> np.ndarray | None:
|
|
days = sample_dates(event, "TR")
|
|
if not days:
|
|
return None
|
|
bodies = TRANSIT_SLOW + (TRANSIT_DAY_EXTRA if event.precision == "day" else ())
|
|
best = np.full(len(self.offsets), np.inf)
|
|
for day in days:
|
|
jd = swe.julday(day.year, day.month, day.day, 12.0)
|
|
for body in bodies:
|
|
lon = planet_lon(jd, body)
|
|
points = [lon, (lon + 180.0) % 360.0] if body == swe.MEAN_NODE else [lon]
|
|
for p in points:
|
|
best = np.minimum(best, aspect_sep(self.asc, p))
|
|
best = np.minimum(best, aspect_sep(self.mc, p))
|
|
return best
|
|
|
|
def _age_days(self, day: date) -> float:
|
|
noon = swe.julday(day.year, day.month, day.day, 12.0)
|
|
return (noon - self.jd[RADIUS_MAX]) / TROPICAL_YEAR
|
|
|
|
def _progressed_angles(self, age_days: float) -> tuple[np.ndarray, np.ndarray]:
|
|
"""Progressed ASC / MC for every candidate: exact every 5 minutes, linear in between (unwrapped)."""
|
|
knots = np.arange(0, len(self.offsets), 5)
|
|
if knots[-1] != len(self.offsets) - 1:
|
|
knots = np.append(knots, len(self.offsets) - 1)
|
|
vals = np.array([angles(self.jd[k] + age_days, self.lat, self.lon) for k in knots])
|
|
idx = np.arange(len(self.offsets))
|
|
asc = np.interp(idx, knots, np.unwrap(np.radians(vals[:, 0]))); mc = np.interp(idx, knots, np.unwrap(np.radians(vals[:, 1])))
|
|
return np.degrees(asc) % 360.0, np.degrees(mc) % 360.0
|
|
|
|
def sep_SP(self, event: Event) -> np.ndarray | None:
|
|
best = np.full(len(self.offsets), np.inf)
|
|
for day in sample_dates(event, "SP"):
|
|
asc, mc = self._progressed_angles(self._age_days(day)) # progressed days == age in years
|
|
for col in range(self.natal.shape[1]):
|
|
t = self.natal[:, col]
|
|
best = np.minimum(best, aspect_sep(asc, t)); best = np.minimum(best, aspect_sep(mc, t))
|
|
return best
|
|
|
|
def sep_SA(self, event: Event) -> np.ndarray | None:
|
|
best = np.full(len(self.offsets), np.inf)
|
|
for day in sample_dates(event, "SA"):
|
|
age_days = self._age_days(day)
|
|
progressed_sun = planet_lon(self.jd[RADIUS_MAX] + age_days, swe.SUN) # Sun moves ~0.04°/h: one value per sample
|
|
arc = (progressed_sun - self.sun) % 360.0
|
|
asc = (self.asc + arc) % 360.0; mc = (self.mc + arc) % 360.0
|
|
for col in range(self.natal.shape[1]):
|
|
t = self.natal[:, col]
|
|
best = np.minimum(best, aspect_sep(asc, t)); best = np.minimum(best, aspect_sep(mc, t))
|
|
return best
|
|
|
|
def separations(self, technique: str, events: Sequence[Event]) -> np.ndarray:
|
|
"""(n_events_used, n_candidates) matrix of minimum separations; events the technique skips are dropped."""
|
|
fn = {"TR": self.sep_TR, "SP": self.sep_SP, "SA": self.sep_SA}[technique]
|
|
rows = [r for r in (fn(e) for e in events) if r is not None]
|
|
return np.array(rows) if rows else np.zeros((0, len(self.offsets)))
|
|
|
|
|
|
def hit_counts(seps: np.ndarray, orb: float) -> np.ndarray:
|
|
return (seps <= orb).sum(axis=0) if seps.size else np.zeros(seps.shape[1] if seps.ndim == 2 else 0, dtype=int)
|
|
|
|
|
|
def truth_rank(scores: np.ndarray, radius: int) -> float:
|
|
"""Fraction of other window minutes that beat the truth minute (ties count half); 0 = truth best, 0.5 = chance."""
|
|
window = scores[RADIUS_MAX - radius: RADIUS_MAX + radius + 1]
|
|
truth = window[radius]
|
|
others = np.delete(window, radius)
|
|
return float(((others > truth).sum() + 0.5 * (others == truth).sum()) / len(others))
|
|
|
|
|
|
def shuffled_events(case: dict[str, Any], rng: Random) -> list[dict[str, Any]]:
|
|
"""Same events, precision and domain, with dates drawn uniformly over the observed life span."""
|
|
birth = birth_local(case).date()
|
|
parsed = [parse_event(e) for e in case["events"]]
|
|
years = [p.start.year for p in parsed if p is not None]
|
|
lo = birth.year + 1
|
|
hi = max(max(years) if years else birth.year + 20, lo + 1)
|
|
out = []
|
|
for raw in case["events"]:
|
|
p = str(raw.get("precision") or "")
|
|
y = rng.randint(lo, hi)
|
|
if p == "day":
|
|
m = rng.randint(1, 12); d = rng.randint(1, calendar.monthrange(y, m)[1]); text = f"{y:04d}-{m:02d}-{d:02d}"
|
|
elif p == "month":
|
|
m = rng.randint(1, 12); text = f"{y:04d}-{m:02d}"
|
|
else:
|
|
text = f"{y:04d}"
|
|
out.append({**raw, "date": text})
|
|
return out
|
|
|
|
|
|
def stable_json(payload: Any) -> str:
|
|
return json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2) + "\n"
|
|
|
|
|
|
def sha256_of(path: Path) -> str:
|
|
return hashlib.sha256(path.read_bytes()).hexdigest()
|