# /// 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, )