269 lines
8.8 KiB
Python
269 lines
8.8 KiB
Python
# /// script
|
|
# requires-python = ">=3.11"
|
|
# dependencies = []
|
|
# ///
|
|
# ─── How to run ───
|
|
# PYTHONPATH=scripts .venv/bin/python scripts/active_rectification_event_engine.py
|
|
"""Local chart and dual-Dasha computation for birth-time event scoring."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import sys
|
|
from datetime import date, datetime, time, timedelta
|
|
from pathlib import Path
|
|
from typing import Final, assert_never
|
|
|
|
from scripts.active_rectification_events import (
|
|
CandidateEvidence,
|
|
CandidateResult,
|
|
CandidateScoreRow,
|
|
EventDomain,
|
|
LifeEvent,
|
|
RectificationEventRequest,
|
|
adjudicate_candidate_rows,
|
|
precision_weight,
|
|
)
|
|
|
|
SCRIPTS: Final = Path(__file__).resolve().parent
|
|
if str(SCRIPTS) not in sys.path:
|
|
sys.path.insert(0, str(SCRIPTS))
|
|
|
|
import dasha_analyzer # noqa: E402
|
|
import domain_calculation_service # noqa: E402
|
|
import narayana_dasha # noqa: E402
|
|
import varga # noqa: E402
|
|
|
|
DomainConfig = tuple[str, tuple[int, ...]]
|
|
DOMAIN_CONFIG: Final[dict[EventDomain, DomainConfig]] = {
|
|
"education": ("D24", (4, 5, 9)),
|
|
"relocation": ("D4", (4, 12)),
|
|
"relationship": ("D9", (7,)),
|
|
"career": ("D10", (10,)),
|
|
"health_pressure": ("D30", (6, 8, 12)),
|
|
}
|
|
|
|
|
|
class RectificationEventCalculationError(RuntimeError):
|
|
"""Raised when stored rectification evidence cannot be calculated safely."""
|
|
|
|
|
|
def _event_datetime(event: LifeEvent) -> datetime:
|
|
match event["precision"]:
|
|
case "day":
|
|
return datetime.strptime(event["date"], "%Y-%m-%d")
|
|
case "month":
|
|
return datetime.strptime(f"{event['date']}-15", "%Y-%m-%d")
|
|
case "year":
|
|
return datetime.strptime(f"{event['date']}-07-01", "%Y-%m-%d")
|
|
case unreachable:
|
|
assert_never(unreachable)
|
|
|
|
|
|
def _candidate_datetimes(request: RectificationEventRequest) -> list[datetime]:
|
|
birth_date = date.fromisoformat(request["birth_date"])
|
|
start = datetime.combine(birth_date, time.fromisoformat(request["start_time"]))
|
|
end = datetime.combine(birth_date, time.fromisoformat(request["end_time"]))
|
|
if end < start:
|
|
end += timedelta(days=1)
|
|
minute_count = int((end - start).total_seconds() // 60) + 1
|
|
if minute_count < 1 or minute_count > 1_440:
|
|
raise RectificationEventCalculationError("candidate_range_out_of_bounds")
|
|
return [start + timedelta(minutes=offset) for offset in range(minute_count)]
|
|
|
|
|
|
def _active_vimshottari(
|
|
birth_date: str,
|
|
moon_longitude: float,
|
|
event_at: datetime,
|
|
) -> tuple[str, str]:
|
|
nakshatra, progress, _ = dasha_analyzer.lon_to_nakshatra(moon_longitude)
|
|
timeline, _, _, _ = dasha_analyzer.build_dasha_timeline(
|
|
birth_date,
|
|
nakshatra,
|
|
progress,
|
|
)
|
|
_, major = dasha_analyzer.find_current(timeline, event_at)
|
|
minor = dasha_analyzer.find_current_sub(
|
|
dasha_analyzer.build_antardasha(major),
|
|
event_at,
|
|
)
|
|
return str(major["lord"]), str(minor["lord"])
|
|
|
|
|
|
def _active_narayana(
|
|
ascendant_index: int,
|
|
planet_longitudes: dict[str, float],
|
|
birth_at: datetime,
|
|
event_at: datetime,
|
|
) -> tuple[int | None, int | None]:
|
|
periods = narayana_dasha.calc_narayana_mahadasha(
|
|
ascendant_index,
|
|
planet_longitudes,
|
|
)
|
|
age = max((event_at - birth_at).total_seconds() / (365.2425 * 86_400), 0.0)
|
|
active = narayana_dasha.get_current_narayana_dasha(periods, age)
|
|
major = active.get("md") or {}
|
|
minor = active.get("ad") or {}
|
|
return major.get("sign_idx"), minor.get("sign_idx")
|
|
|
|
|
|
def _varga_chart(charts: dict, prefix: str) -> dict | None:
|
|
return next(
|
|
(chart for name, chart in charts.items() if name.startswith(f"{prefix}_")),
|
|
None,
|
|
)
|
|
|
|
|
|
def _relative_house(sign_index: int, ascendant_index: int) -> int:
|
|
return (sign_index - ascendant_index) % 12 + 1
|
|
|
|
|
|
def _house_lords(ascendant_index: int, houses: tuple[int, ...]) -> set[str]:
|
|
return {
|
|
narayana_dasha.SIGN_LORDS[
|
|
narayana_dasha.SIGNS[(ascendant_index + house - 1) % 12]
|
|
]
|
|
for house in houses
|
|
}
|
|
|
|
|
|
def _planet_house(chart: dict, planet: str) -> int | None:
|
|
raw = (chart.get("planets", {}).get(planet) or {}).get("house")
|
|
return int(raw) if isinstance(raw, int | float) else None
|
|
|
|
|
|
def _varga_house(chart: dict, planet: str) -> int | None:
|
|
ascendant = chart.get("Ascendant") or {}
|
|
placement = chart.get(planet) or {}
|
|
ascendant_index = ascendant.get("sign_idx")
|
|
planet_index = placement.get("sign_idx")
|
|
if not isinstance(ascendant_index, int) or not isinstance(planet_index, int):
|
|
return None
|
|
return _relative_house(planet_index, ascendant_index)
|
|
|
|
|
|
def _score_event(
|
|
*,
|
|
candidate_time: str,
|
|
event: LifeEvent,
|
|
natal_chart: dict,
|
|
varga_chart: dict,
|
|
vimshottari: tuple[str, str],
|
|
narayana: tuple[int | None, int | None],
|
|
) -> CandidateEvidence:
|
|
_, target_houses = DOMAIN_CONFIG[event["domain"]]
|
|
ascendant_index = int(natal_chart["ascendant"]["lon"] // 30)
|
|
target_lords = _house_lords(ascendant_index, target_houses)
|
|
major_lord, minor_lord = vimshottari
|
|
rules: list[str] = []
|
|
points = 0.0
|
|
|
|
for lord, weight, label in (
|
|
(major_lord, 2.0, "vim_md"),
|
|
(minor_lord, 1.5, "vim_ad"),
|
|
):
|
|
if _planet_house(natal_chart, lord) in target_houses:
|
|
rules.append(f"{label}_domain_house")
|
|
points += weight
|
|
if lord in target_lords:
|
|
rules.append(f"{label}_domain_lord")
|
|
points += weight
|
|
if _varga_house(varga_chart, lord) in target_houses:
|
|
rules.append(f"{label}_domain_varga")
|
|
points += weight / 2
|
|
|
|
for sign_index, weight, label in (
|
|
(narayana[0], 2.0, "narayana_md"),
|
|
(narayana[1], 1.0, "narayana_ad"),
|
|
):
|
|
if sign_index is not None and _relative_house(sign_index, ascendant_index) in target_houses:
|
|
rules.append(f"{label}_domain_house")
|
|
points += weight
|
|
|
|
weighted_points = round(points * precision_weight(event["precision"]), 4)
|
|
return {
|
|
"event_id": event["id"],
|
|
"domain": event["domain"],
|
|
"candidate_time": candidate_time,
|
|
"rule_ids": rules or ["no_domain_activation"],
|
|
"points": weighted_points,
|
|
}
|
|
|
|
|
|
def _candidate_row(
|
|
request: RectificationEventRequest,
|
|
candidate_at: datetime,
|
|
) -> CandidateScoreRow:
|
|
chart = domain_calculation_service.compute_chart({
|
|
"year": candidate_at.year,
|
|
"month": candidate_at.month,
|
|
"day": candidate_at.day,
|
|
"hour": candidate_at.hour,
|
|
"minute": candidate_at.minute,
|
|
"lat": request["lat"],
|
|
"lon": request["lon"],
|
|
"tz": request["tz"],
|
|
"ayanamsa": "lahiri",
|
|
"node_mode": "true",
|
|
})
|
|
planet_longitudes = {
|
|
name: float(data["lon"])
|
|
for name, data in chart.get("planets", {}).items()
|
|
if isinstance(data, dict) and isinstance(data.get("lon"), int | float)
|
|
}
|
|
ascendant_longitude = float(chart["ascendant"]["lon"])
|
|
ascendant_index = int(ascendant_longitude // 30)
|
|
charts = varga.calc_all_vargas(
|
|
planet_longitudes,
|
|
ascendant_longitude,
|
|
divisions=[4, 9, 10, 24, 30],
|
|
)
|
|
moon_longitude = planet_longitudes["Moon"]
|
|
evidence: list[CandidateEvidence] = []
|
|
missing_layers: list[str] = []
|
|
|
|
for event in request["events"]:
|
|
event_at = _event_datetime(event)
|
|
prefix, _ = DOMAIN_CONFIG[event["domain"]]
|
|
domain_varga = _varga_chart(charts, prefix)
|
|
if domain_varga is None:
|
|
missing_layers.append(prefix)
|
|
continue
|
|
vimshottari = _active_vimshottari(request["birth_date"], moon_longitude, event_at)
|
|
narayana = _active_narayana(
|
|
ascendant_index,
|
|
planet_longitudes,
|
|
candidate_at,
|
|
event_at,
|
|
)
|
|
evidence.append(_score_event(
|
|
candidate_time=candidate_at.strftime("%H:%M"),
|
|
event=event,
|
|
natal_chart=chart,
|
|
varga_chart=domain_varga,
|
|
vimshottari=vimshottari,
|
|
narayana=narayana,
|
|
))
|
|
|
|
return {
|
|
"time": candidate_at.strftime("%H:%M"),
|
|
"score": round(sum(item["points"] for item in evidence), 4),
|
|
"evidence": evidence,
|
|
"missing_layers": sorted(set(missing_layers)),
|
|
}
|
|
|
|
|
|
def compute_event_candidate_result(request: RectificationEventRequest) -> CandidateResult:
|
|
"""Compute actual minute candidates locally and return a guarded result."""
|
|
normalized = json.dumps(request, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
|
|
fingerprint = hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
|
rows = [_candidate_row(request, candidate) for candidate in _candidate_datetimes(request)]
|
|
return adjudicate_candidate_rows(
|
|
rows,
|
|
event_count=len(request["events"]),
|
|
domain_count=len({event["domain"] for event in request["events"]}),
|
|
request_fingerprint=fingerprint,
|
|
)
|