TASK-consult-evidence-card-v2-20260927 T1. New module scripts/consultation_native_layers.py, thinly registered after the merged engine fields in _attach_local_consultation_layers (no handler method, no new forgery site). It writes one new top-level chart key, chart.consultation_native_layers, kept out of chart.modules so the thematic report's full_reading_module_count does not move: - d9_summary: D9 lagna, each planet's D9 sign and dignity (jyotish_engine._get_dignity_level), Vargottama (_calc_vargottama), D1<->D9 reversals - punarphoo (punarphoo.detect_punarphoo, observation_only) - vivah_saham (jyotish_engine._calc_vivah_saham), day_night (gulika) - slow_transits: Jupiter / Saturn / Rahu / Ketu now with natal house and the Jupiter / Saturn SAV / BAV, and the next twelve months' ingress / station dates (ephemeris_events; nodes by the same daily-noon sampling) - double_transit: cmd_double_transit_pac for houses 1-12, conclusions only, plus DK / UL targets by the same PAC rule - karakamsha, argala, dispositor_chains, inter_chart_linkage, moon_transit (lookup-only layers) - annual_tajika on the annual route: build_annual_tajika_pack (annual lagna, Varshesha, Muntha, Mudda Dasha with dates, Sun / Moon / year-lord Tajika aspects), parameter_sensitive; blocked when the pack is not reliable, never natal data A/B on 3 public AA charts x 4 routes: every existing output is identical, only the new key is added; about 40 ms per workflow. Golden regenerated with annual and timing routes (family workflow byte-identical apart from the new key). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
772 lines
35 KiB
Python
772 lines
35 KiB
Python
#!/usr/bin/env python3
|
|
"""Expose existing native technique modules in the web consultation output.
|
|
|
|
TASK-consult-evidence-card-v2-20260927 T1. The consultation workflow already
|
|
computes the chart, the formal vargas, Ashtakavarga, Arudha padas and the chara
|
|
karakas, but several techniques the repository implements never reached its
|
|
output: the D9 dignity layer and Vargottama, Punarphoo, Double Transit, the
|
|
Rahu/Ketu and slow-planet ingress and station dates, Vivah Saham, Karakamsha,
|
|
Argala, the dispositor chain, the D1/D9/D10 linkage, the Moon transit and the
|
|
annual Tajika / Varshaphala pack.
|
|
|
|
This module only adds output. Every value comes from a native producer
|
|
(`jyotish_engine`, `punarphoo`, `ephemeris_events`, `jaimini`, `argala`,
|
|
`annual_tajika_pack`); what this module adds on top is selection, the
|
|
whole-sign house of a sign that a producer already returned, and the calendar
|
|
dates of the Mudda Dasha segments (see `_mudda_segments`). It does not change
|
|
any existing module, score, candidate or frozen scoring file, and it writes a
|
|
single new top-level chart key, `chart.consultation_native_layers`, which no
|
|
existing reader consumes. It is kept out of `chart.modules` on purpose: the
|
|
thematic report counts the modules it was handed (`full_reading_module_count`)
|
|
and that existing value must not move.
|
|
|
|
The annual pack is attached only when the declared route is `annual`. When the
|
|
pack cannot be produced reliably it is reported as blocked with the reason;
|
|
natal data is never substituted for the annual chart.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
SCHEMA = "jyotish.consultation_native_layers.v1"
|
|
SIGNS = (
|
|
"Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
|
|
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
|
|
)
|
|
TRANSIT_WINDOW_DAYS = 365
|
|
MAX_DOUBLE_TRANSIT_CONCLUSIONS = 24
|
|
_STRONG_DIGNITIES = frozenset({"EXALTED", "MOOLATRIKONA", "OWN_SIGN"})
|
|
_WEAK_DIGNITIES = frozenset({"DEBILITATED", "NEECHA_BHANGA"})
|
|
REVERSAL_RULE = (
|
|
"D1 in EXALTED / MOOLATRIKONA / OWN_SIGN and D9 in DEBILITATED / NEECHA_BHANGA, or the reverse; "
|
|
"levels from jyotish_engine._get_dignity_level"
|
|
)
|
|
TAJIKA_BOUNDARY = (
|
|
"parameter_sensitive: Tajika / Varshaphala is verified on the first target set only, "
|
|
"not a global closure; annual structure, not an event verdict"
|
|
)
|
|
|
|
|
|
def _scripts_on_path() -> None:
|
|
scripts_dir = str(Path(__file__).resolve().parent)
|
|
if scripts_dir not in sys.path:
|
|
sys.path.insert(0, scripts_dir)
|
|
|
|
|
|
def _load_engine():
|
|
# Same loader as consultation_engine_field_bridge: reuse the engine module
|
|
# the consultation path already imported instead of loading another copy.
|
|
_scripts_on_path()
|
|
try:
|
|
from scripts import jyotish_engine as engine
|
|
except ModuleNotFoundError: # pragma: no cover - script execution
|
|
import jyotish_engine as engine
|
|
return engine
|
|
|
|
|
|
def _record(value: Any) -> dict:
|
|
return value if isinstance(value, dict) else {}
|
|
|
|
|
|
def _sign_index(sign: Any) -> int | None:
|
|
return SIGNS.index(sign) if sign in SIGNS else None
|
|
|
|
|
|
def _whole_sign_house(sign: Any, lagna_sign_idx: int | None) -> int | None:
|
|
idx = _sign_index(sign)
|
|
if idx is None or lagna_sign_idx is None:
|
|
return None
|
|
return ((idx - lagna_sign_idx) % 12) + 1
|
|
|
|
|
|
def _blocked(exc: Exception | str) -> dict:
|
|
reason = exc if isinstance(exc, str) else f"{exc.__class__.__name__}"
|
|
return {"status": "blocked", "reason": reason}
|
|
|
|
|
|
def _planet_lon(pdata: Any) -> float | None:
|
|
row = _record(pdata)
|
|
for key in ("lon", "degree_raw"):
|
|
value = row.get(key)
|
|
if isinstance(value, (int, float)):
|
|
return float(value)
|
|
return None
|
|
|
|
|
|
def _lagna_idx(chart: dict) -> int | None:
|
|
asc = _record(chart.get("ascendant"))
|
|
if isinstance(asc.get("sign_idx"), int):
|
|
return int(asc["sign_idx"]) % 12
|
|
return _sign_index(asc.get("sign"))
|
|
|
|
|
|
def _d9_chart(modules: dict) -> dict:
|
|
return _record(_record(modules.get("varga_full")).get("D9_Navamsa"))
|
|
|
|
|
|
# ── D9 summary ──────────────────────────────────────────────────────────────
|
|
|
|
def build_d9_summary(engine, chart: dict, modules: dict) -> dict:
|
|
"""D9 lagna, each planet's D9 sign and dignity, Vargottama, D1↔D9 reversals.
|
|
|
|
D9 dignity: `jyotish_engine._get_dignity_level` with the context rule the
|
|
full reading's D9 step uses (`_build_dignity_context` of the D9 positions).
|
|
D1 dignity for the reversal check is the chart's own `status` label. The
|
|
D1 label itself is already in the base section, so it is not repeated.
|
|
Vargottama comes from `jyotish_engine._calc_vargottama`.
|
|
"""
|
|
d9 = _d9_chart(modules)
|
|
d9_planets = _record(d9.get("planets"))
|
|
planets = _record(chart.get("planets"))
|
|
if not d9_planets or not planets:
|
|
return _blocked("d9_or_d1_positions_missing")
|
|
d9_context = engine._build_dignity_context(d9_planets)
|
|
vargottama = engine._calc_vargottama(planets, {"D9_Navamsa": d9_planets})
|
|
labels_to_level = {label: level for level, label in engine.DIGNITY_LABELS.items()}
|
|
rows: dict[str, dict] = {}
|
|
reversals: list[dict] = []
|
|
for name, pdata in planets.items():
|
|
d9_row = _record(d9_planets.get(name))
|
|
if not isinstance(pdata, dict) or not d9_row.get("sign") or not pdata.get("sign"):
|
|
continue
|
|
d9_level = engine._get_dignity_level(name, d9_row["sign"], d9_row.get("degree"), d9_context)
|
|
# D1: the chart's own status label (what the base section shows); its
|
|
# level is read back from the same label table.
|
|
d1_label = pdata.get("status")
|
|
d1_level = labels_to_level.get(d1_label) if isinstance(d1_label, str) else None
|
|
if d1_level is None:
|
|
d1_level = engine._get_dignity_level(name, pdata["sign"], pdata.get("degree_in_sign"), planets)
|
|
d1_label = engine.DIGNITY_LABELS.get(d1_level, d1_level)
|
|
is_vargottama = bool(_record(vargottama.get(name)).get("is_vargottama"))
|
|
d9_label = engine.DIGNITY_LABELS.get(d9_level, d9_level)
|
|
rows[name] = {
|
|
"d9_sign": d9_row["sign"],
|
|
"d9_dignity": d9_label,
|
|
"vargottama": is_vargottama,
|
|
}
|
|
if (d1_level in _STRONG_DIGNITIES and d9_level in _WEAK_DIGNITIES) or (
|
|
d1_level in _WEAK_DIGNITIES and d9_level in _STRONG_DIGNITIES
|
|
):
|
|
reversals.append({"planet": name, "d1": d1_label, "d9": d9_label})
|
|
d9_lagna = _record(d9.get("ascendant")).get("sign")
|
|
d1_lagna = _record(chart.get("ascendant")).get("sign")
|
|
return {
|
|
"status": "executed",
|
|
"source": "jyotish_engine._get_dignity_level / _calc_vargottama on modules.varga_full.D9_Navamsa",
|
|
"lagna": d9_lagna,
|
|
"lagna_vargottama": bool(d9_lagna and d9_lagna == d1_lagna),
|
|
"planets": rows,
|
|
"vargottama": [name for name, row in rows.items() if row["vargottama"]],
|
|
"reversals": reversals,
|
|
"reversal_rule": REVERSAL_RULE,
|
|
}
|
|
|
|
|
|
# ── transit positions at the reference date ─────────────────────────────────
|
|
|
|
def _noon_local_jd(swe, day: datetime, tz: float) -> float:
|
|
return swe.julday(day.year, day.month, day.day, 12.0 - float(tz))
|
|
|
|
|
|
def _transit_positions(engine, day: datetime, tz: float, node_mode: str) -> dict:
|
|
positions, _ = engine._calc_sidereal_planets_for_jd(
|
|
_noon_local_jd(engine.swe, day, tz), node_mode=node_mode, include_ketu=True,
|
|
)
|
|
return positions if isinstance(positions, dict) else {}
|
|
|
|
|
|
def _node_signs(engine, day: datetime, node_mode: str) -> dict[str, str]:
|
|
jd = engine.swe.julday(day.year, day.month, day.day, 12.0)
|
|
positions, _ = engine._calc_sidereal_planets_for_jd(jd, node_mode=node_mode, include_ketu=True)
|
|
return {
|
|
body: _record(_record(positions).get(body)).get("sign")
|
|
for body in ("Rahu", "Ketu")
|
|
if _record(_record(positions).get(body)).get("sign")
|
|
}
|
|
|
|
|
|
def _node_ingresses(engine, start: datetime, days: int, node_mode: str, lagna_idx: int | None) -> list[dict]:
|
|
"""Rahu / Ketu sign changes, sampled like ephemeris_events (daily noon UT).
|
|
|
|
The mean node moves steadily about 0.05 degrees a day, so for it the scan
|
|
steps a week at a time and resolves the day only inside a week whose sign
|
|
changed; the reported date is the same first daily-noon sample in the new
|
|
sign. The true node wobbles and can cross a boundary and back within a
|
|
week, so it is sampled every day.
|
|
"""
|
|
step = 7 if node_mode == "mean" else 1
|
|
events: list[dict] = []
|
|
previous_day = start
|
|
previous = _node_signs(engine, start, node_mode)
|
|
offset = 0
|
|
while offset < days:
|
|
offset = min(offset + step, days)
|
|
day = start + timedelta(days=offset)
|
|
signs = _node_signs(engine, day, node_mode)
|
|
if signs != previous:
|
|
prior = previous
|
|
probe = previous_day
|
|
while probe < day:
|
|
probe += timedelta(days=1)
|
|
current = _node_signs(engine, probe, node_mode)
|
|
for body in ("Rahu", "Ketu"):
|
|
if prior.get(body) and current.get(body) and current[body] != prior[body]:
|
|
events.append({
|
|
"kind": "ingress",
|
|
"date": probe.strftime("%Y-%m-%d"),
|
|
"body": body.lower(),
|
|
"from_sign": prior[body],
|
|
"to_sign": current[body],
|
|
"to_natal_house": _whole_sign_house(current[body], lagna_idx),
|
|
})
|
|
prior = current
|
|
previous, previous_day = signs, day
|
|
return events
|
|
|
|
|
|
def build_slow_transits(engine, chart: dict, modules: dict, *, reference: datetime, tz: float,
|
|
ayanamsa: str, node_mode: str) -> dict:
|
|
"""Jupiter / Saturn / Rahu / Ketu now, and the key dates of the next twelve months.
|
|
|
|
Positions: `jyotish_engine._calc_sidereal_planets_for_jd` at local noon of
|
|
the reference date. Jupiter / Saturn ingress and station dates:
|
|
`ephemeris_events.build_ephemeris_events`. Rahu / Ketu ingress dates: the
|
|
same daily-noon sampling over the engine's node positions (the ephemeris
|
|
event scanner has no nodes). SAV / BAV of the Jupiter and Saturn signs are
|
|
read from modules.ashtakavarga (sav.scores, bav.<planet>.bindus).
|
|
"""
|
|
try:
|
|
from ephemeris_events import build_ephemeris_events
|
|
except ModuleNotFoundError: # pragma: no cover
|
|
from scripts.ephemeris_events import build_ephemeris_events
|
|
lagna_idx = _lagna_idx(chart)
|
|
positions = _transit_positions(engine, reference, tz, node_mode)
|
|
ashtakavarga = _record(modules.get("ashtakavarga"))
|
|
sav_scores = _record(_record(ashtakavarga.get("sav")).get("scores"))
|
|
bav = _record(ashtakavarga.get("bav"))
|
|
current: dict[str, dict] = {}
|
|
for body in ("Jupiter", "Saturn", "Rahu", "Ketu"):
|
|
row = _record(positions.get(body))
|
|
if not row.get("sign"):
|
|
continue
|
|
entry = {
|
|
"sign": row["sign"],
|
|
"degree_in_sign": row.get("degree_in_sign"),
|
|
"retrograde": row.get("retrograde"),
|
|
"natal_house": _whole_sign_house(row["sign"], lagna_idx),
|
|
}
|
|
if body in ("Jupiter", "Saturn"):
|
|
idx = _sign_index(row["sign"])
|
|
if row["sign"] in sav_scores:
|
|
entry["sav"] = sav_scores[row["sign"]]
|
|
bindus = _record(bav.get(body)).get("bindus")
|
|
if isinstance(bindus, list) and idx is not None and idx < len(bindus):
|
|
entry["bav"] = bindus[idx]
|
|
current[body] = entry
|
|
start = reference.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=None)
|
|
end = start + timedelta(days=TRANSIT_WINDOW_DAYS)
|
|
scan = build_ephemeris_events({
|
|
"start_date": start.strftime("%Y-%m-%d"),
|
|
"end_date": end.strftime("%Y-%m-%d"),
|
|
"ayanamsa": ayanamsa,
|
|
"node_mode": node_mode,
|
|
"bodies": ["jupiter", "saturn"],
|
|
})
|
|
events: list[dict] = []
|
|
for item in scan.get("events") or []:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
row = {"kind": item.get("kind"), "date": item.get("date"), "body": item.get("body")}
|
|
if item.get("kind") == "ingress":
|
|
row["from_sign"] = item.get("from_sign")
|
|
row["to_sign"] = item.get("to_sign")
|
|
row["to_natal_house"] = _whole_sign_house(item.get("to_sign"), lagna_idx)
|
|
else:
|
|
row["motion"] = item.get("motion")
|
|
events.append(row)
|
|
events.extend(_node_ingresses(engine, start, TRANSIT_WINDOW_DAYS, node_mode, lagna_idx))
|
|
events.sort(key=lambda row: (str(row.get("date")), str(row.get("body")), str(row.get("kind"))))
|
|
houses_in_window: dict[str, list[int]] = {}
|
|
for body in ("Jupiter", "Saturn"):
|
|
houses: list[int] = []
|
|
first = _record(current.get(body)).get("natal_house")
|
|
if isinstance(first, int):
|
|
houses.append(first)
|
|
for row in events:
|
|
if row.get("body") == body.lower() and row.get("kind") == "ingress":
|
|
house = row.get("to_natal_house")
|
|
if isinstance(house, int) and house not in houses:
|
|
houses.append(house)
|
|
houses_in_window[body] = houses
|
|
return {
|
|
"status": "executed",
|
|
"source": "jyotish_engine._calc_sidereal_planets_for_jd + ephemeris_events.build_ephemeris_events",
|
|
"reference_date": reference.strftime("%Y-%m-%d"),
|
|
"window": {"start": start.strftime("%Y-%m-%d"), "end": end.strftime("%Y-%m-%d")},
|
|
"current": current,
|
|
"key_dates": events,
|
|
"houses_in_window": houses_in_window,
|
|
"boundary": scan.get("boundary") or "sign ingress and station scan, not a natal trigger",
|
|
}
|
|
|
|
|
|
def build_moon_transit(engine, chart: dict, *, reference: datetime, tz: float, node_mode: str) -> dict:
|
|
lagna_idx = _lagna_idx(chart)
|
|
moon = _record(_transit_positions(engine, reference, tz, node_mode).get("Moon"))
|
|
natal_moon = _record(_record(chart.get("planets")).get("Moon"))
|
|
if not moon.get("sign"):
|
|
return _blocked("moon_transit_unavailable")
|
|
return {
|
|
"status": "executed",
|
|
"source": "jyotish_engine._calc_sidereal_planets_for_jd (local noon)",
|
|
"date": reference.strftime("%Y-%m-%d"),
|
|
"sign": moon["sign"],
|
|
"degree_in_sign": moon.get("degree_in_sign"),
|
|
"natal_house": _whole_sign_house(moon["sign"], lagna_idx),
|
|
"house_from_natal_moon": _whole_sign_house(moon["sign"], _sign_index(natal_moon.get("sign"))),
|
|
"boundary": "month / day-level trigger only; changes sign about every 2.5 days",
|
|
}
|
|
|
|
|
|
# ── Punarphoo, Vivah Saham, day / night ─────────────────────────────────────
|
|
|
|
def build_punarphoo(chart: dict, transit_saturn_lon: float | None) -> dict:
|
|
try:
|
|
from punarphoo import detect_punarphoo
|
|
except ModuleNotFoundError: # pragma: no cover
|
|
from scripts.punarphoo import detect_punarphoo
|
|
result = detect_punarphoo(_record(chart.get("planets")), transit_saturn_longitude=transit_saturn_lon)
|
|
transit = _record(result.get("saturn_return_to_natal_moon"))
|
|
return {
|
|
"status": result.get("status"),
|
|
"source": "punarphoo.detect_punarphoo",
|
|
"natal_present": result.get("natal_present"),
|
|
"orb_deg": result.get("orb_deg"),
|
|
"orb_limit_deg": result.get("orb_limit_deg"),
|
|
"same_house": result.get("same_house"),
|
|
"saturn_return_to_natal_moon": {
|
|
"active": transit.get("active"),
|
|
"orb_deg": transit.get("orb_deg"),
|
|
"orb_limit_deg": transit.get("orb_limit_deg"),
|
|
},
|
|
"claim_boundary": result.get("claim_boundary"),
|
|
}
|
|
|
|
|
|
def build_vivah_saham(engine, chart: dict) -> dict:
|
|
"""Natal Vivah Saham from the full reading's own producer (Step 16)."""
|
|
planets = {
|
|
name: {"degree": lon}
|
|
for name, lon in ((name, _planet_lon(row)) for name, row in _record(chart.get("planets")).items())
|
|
if lon is not None
|
|
}
|
|
asc_lon = _planet_lon(chart.get("ascendant"))
|
|
if not planets or asc_lon is None:
|
|
return _blocked("natal_longitudes_missing")
|
|
result = engine._calc_vivah_saham(planets, asc_lon)
|
|
if not isinstance(result, dict) or result.get("error"):
|
|
return _blocked(str(_record(result).get("error") or "vivah_saham_unavailable"))
|
|
return {
|
|
"status": "executed",
|
|
"source": "jyotish_engine._calc_vivah_saham",
|
|
"longitude": result.get("saham_lon"),
|
|
"sign": result.get("saham_sign"),
|
|
"degree_in_sign": result.get("saham_deg_in_sign"),
|
|
"house": result.get("saham_house"),
|
|
"natal_conjuncts": result.get("natal_conjuncts") or [],
|
|
"boundary": result.get("note"),
|
|
}
|
|
|
|
|
|
def build_day_night(modules: dict) -> dict:
|
|
evidence = _record(_record(modules.get("gulika")).get("daynight_evidence"))
|
|
if not isinstance(evidence.get("is_daytime"), bool):
|
|
return _blocked("daynight_evidence_missing")
|
|
return {
|
|
"status": "executed",
|
|
"source": "chart.modules.gulika.daynight_evidence (swisseph.rise_trans)",
|
|
"is_day": evidence["is_daytime"],
|
|
}
|
|
|
|
|
|
# ── Double Transit ──────────────────────────────────────────────────────────
|
|
|
|
def _pac_types(rows: Any) -> list[str]:
|
|
return [str(row.get("type")) for row in rows if isinstance(row, dict) and row.get("type")] if isinstance(rows, list) else []
|
|
|
|
|
|
def _double_transit_args(birth_payload: dict, reference: datetime, house: int):
|
|
from types import SimpleNamespace
|
|
|
|
return SimpleNamespace(
|
|
year=int(birth_payload["year"]), month=int(birth_payload["month"]), day=int(birth_payload["day"]),
|
|
hour=int(birth_payload["hour"]), minute=int(birth_payload["minute"]),
|
|
second=birth_payload.get("second") or 0,
|
|
lat=float(birth_payload["lat"]), lon=float(birth_payload["lon"]), tz=float(birth_payload["tz"]),
|
|
node_mode=str(birth_payload.get("node_mode") or "mean"),
|
|
ayanamsa=birth_payload.get("ayanamsa"),
|
|
date=reference.strftime("%Y-%m-%d"), house=house,
|
|
)
|
|
|
|
|
|
def build_double_transit(engine, chart: dict, modules: dict, *, birth_payload: dict, reference: datetime,
|
|
transit_positions: dict) -> dict:
|
|
"""Jupiter + Saturn activation per house from `jyotish_engine.cmd_double_transit_pac`.
|
|
|
|
The engine checks one event house per call (house, its lord, the lagna
|
|
lord, the opposite lord; the same in D9 and from the Moon); all twelve are
|
|
run. DK and UL are not targets of that producer, so the same PAC rule
|
|
(`jyotish_engine._check_pac`) is applied to the DK longitude and the UL
|
|
sign for the marriage card; those rows say so.
|
|
"""
|
|
conclusions: list[dict] = []
|
|
summaries: dict[str, str] = {}
|
|
for house in range(1, 13):
|
|
result = engine.cmd_double_transit_pac(_double_transit_args(birth_payload, reference, house))
|
|
if not isinstance(result, dict) or result.get("error"):
|
|
return _blocked(str(_record(result).get("error") or "double_transit_unavailable"))
|
|
summaries[str(house)] = str(result.get("summary") or "")
|
|
for row in result.get("double_transit") or []:
|
|
if not isinstance(row, dict):
|
|
continue
|
|
conclusions.append({
|
|
"house": house,
|
|
"layer": row.get("layer"),
|
|
"target": row.get("target"),
|
|
"strength": row.get("strength"),
|
|
"jupiter_pac": _pac_types(row.get("jupiter_pac")),
|
|
"saturn_pac": _pac_types(row.get("saturn_pac")),
|
|
})
|
|
active_houses = sorted({row["house"] for row in conclusions})
|
|
lagna_idx = _lagna_idx(chart)
|
|
jupiter_lon = _planet_lon({"lon": _record(transit_positions.get("Jupiter")).get("degree_raw")})
|
|
saturn_lon = _planet_lon({"lon": _record(transit_positions.get("Saturn")).get("degree_raw")})
|
|
karaka_targets: dict[str, dict] = {}
|
|
if lagna_idx is not None and jupiter_lon is not None and saturn_lon is not None:
|
|
dk = _record(_record(_record(modules.get("jaimini")).get("chara_karakas")).get("DK"))
|
|
dk_planet = dk.get("planet")
|
|
dk_lon = _planet_lon(_record(chart.get("planets")).get(dk_planet)) if dk_planet else None
|
|
ul_sign = _record(_record(_record(modules.get("arudha_padas")).get("padas")).get("UL")).get("sign")
|
|
ul_idx = _sign_index(ul_sign)
|
|
targets = []
|
|
if dk_lon is not None:
|
|
targets.append(("DK", f"DK({dk_planet})", dk_lon))
|
|
if ul_idx is not None:
|
|
targets.append(("UL", f"UL({ul_sign})", ul_idx * 30 + 15))
|
|
for key, label, target_lon in targets:
|
|
jupiter = _pac_types(engine._check_pac("Jupiter", jupiter_lon, target_lon, lagna_idx))
|
|
saturn = _pac_types(engine._check_pac("Saturn", saturn_lon, target_lon, lagna_idx))
|
|
karaka_targets[key] = {
|
|
"target": label,
|
|
"jupiter_pac": jupiter,
|
|
"saturn_pac": saturn,
|
|
"double_transit": bool(jupiter and saturn),
|
|
"method": "jyotish_engine._check_pac (same PAC rule as cmd_double_transit_pac; not a target of that producer)",
|
|
}
|
|
return {
|
|
"status": "executed",
|
|
"source": "jyotish_engine.cmd_double_transit_pac (houses 1-12)",
|
|
"date": reference.strftime("%Y-%m-%d"),
|
|
"active_houses": active_houses,
|
|
"conclusions": conclusions[:MAX_DOUBLE_TRANSIT_CONCLUSIONS],
|
|
"conclusion_count": len(conclusions),
|
|
"house_7_summary": summaries.get("7"),
|
|
"karaka_targets": karaka_targets,
|
|
"boundary": "Jupiter and Saturn activation of the same target on one date; an activation window, not an event",
|
|
}
|
|
|
|
|
|
# ── Jaimini / lookup layers ─────────────────────────────────────────────────
|
|
|
|
def build_karakamsha(chart: dict, modules: dict) -> dict:
|
|
try:
|
|
from jaimini import calc_karakamsha
|
|
except ModuleNotFoundError: # pragma: no cover
|
|
from scripts.jaimini import calc_karakamsha
|
|
ak = _record(_record(_record(modules.get("jaimini")).get("chara_karakas")).get("AK"))
|
|
ak_planet = ak.get("planet")
|
|
ak_d9 = _record(_record(_d9_chart(modules).get("planets")).get(ak_planet)) if ak_planet else {}
|
|
if not ak_d9.get("sign"):
|
|
return _blocked("atmakaraka_d9_missing")
|
|
result = calc_karakamsha(ak_d9["sign"], ak_d9.get("degree"))
|
|
return {
|
|
"status": "executed",
|
|
"source": "jaimini.calc_karakamsha (AK in D9)",
|
|
"atmakaraka": ak_planet,
|
|
"karakamsha_sign": result.get("karakamsha_sign"),
|
|
"karakamsha_degree": result.get("karakamsha_degree"),
|
|
"karakamsha_lord": result.get("karakamsha_lord"),
|
|
"karakamsha_natal_house": _whole_sign_house(result.get("karakamsha_sign"), _lagna_idx(chart)),
|
|
}
|
|
|
|
|
|
def build_argala(chart: dict) -> dict:
|
|
try:
|
|
from argala import calc_argala
|
|
except ModuleNotFoundError: # pragma: no cover
|
|
from scripts.argala import calc_argala
|
|
lagna_idx = _lagna_idx(chart)
|
|
indices = {
|
|
name: _sign_index(row.get("sign"))
|
|
for name, row in _record(chart.get("planets")).items()
|
|
if isinstance(row, dict) and _sign_index(row.get("sign")) is not None
|
|
}
|
|
if lagna_idx is None or not indices:
|
|
return _blocked("argala_inputs_missing")
|
|
result = calc_argala(indices, lagna_idx)
|
|
return {"status": "executed", "source": "argala.calc_argala", **result}
|
|
|
|
|
|
def build_dispositor_chains(engine, chart: dict) -> dict:
|
|
chains = engine.calc_all_dispositor_chains(_record(chart.get("planets")))
|
|
compact = {
|
|
name: [
|
|
{"planet": step.get("planet"), "sign": step.get("sign"), "dispositor": step.get("dispositor")}
|
|
for step in chain if isinstance(step, dict)
|
|
]
|
|
for name, chain in _record(chains).items() if isinstance(chain, list)
|
|
}
|
|
return {
|
|
"status": "executed" if compact else "blocked",
|
|
"source": "jyotish_engine.calc_all_dispositor_chains",
|
|
"chains": compact,
|
|
"boundary": "D1 dispositor chains; parameter_sensitive",
|
|
}
|
|
|
|
|
|
def build_inter_chart_linkage(engine, chart: dict, modules: dict) -> dict:
|
|
varga_full = _record(modules.get("varga_full"))
|
|
|
|
def flat(code: str) -> tuple[dict, int | None]:
|
|
data = _record(varga_full.get(code))
|
|
return _record(data.get("planets")), _sign_index(_record(data.get("ascendant")).get("sign"))
|
|
|
|
d9, d9_idx = flat("D9_Navamsa")
|
|
d10, d10_idx = flat("D10_Dasamsa")
|
|
if not d9 or not d10:
|
|
return _blocked("d9_or_d10_missing")
|
|
linkage = engine.calc_all_inter_chart_linkages(
|
|
_record(chart.get("planets")), d9, d10, None, _lagna_idx(chart), d9_idx, d10_idx,
|
|
)
|
|
compact = {
|
|
name: {
|
|
code: {"sign": point.get("sign"), "house": point.get("house"), "lord": point.get("lord")}
|
|
for code, point in _record(row).items() if isinstance(point, dict)
|
|
}
|
|
for name, row in _record(linkage).items()
|
|
}
|
|
return {
|
|
"status": "executed",
|
|
"source": "jyotish_engine.calc_all_inter_chart_linkages (D1 / D9 / D10)",
|
|
"planets": compact,
|
|
"boundary": "placement linkage only; parameter_sensitive",
|
|
}
|
|
|
|
|
|
# ── Annual Tajika / Varshaphala ─────────────────────────────────────────────
|
|
|
|
def _annual_payload(birth_payload: dict, target_year: int) -> dict:
|
|
tz = float(birth_payload["tz"])
|
|
sign = "-" if tz < 0 else "+"
|
|
minutes = int(round(abs(tz) * 60))
|
|
second = int(float(birth_payload.get("second") or 0))
|
|
return {
|
|
"birth": {
|
|
"date": f"{int(birth_payload['year']):04d}-{int(birth_payload['month']):02d}-{int(birth_payload['day']):02d}",
|
|
"time": f"{int(birth_payload['hour']):02d}:{int(birth_payload['minute']):02d}:{second:02d}",
|
|
"latitude": float(birth_payload["lat"]),
|
|
"longitude": float(birth_payload["lon"]),
|
|
"utc_offset": f"{sign}{minutes // 60:02d}:{minutes % 60:02d}",
|
|
},
|
|
"settings": {
|
|
"ayanamsa": birth_payload.get("ayanamsa") or "raman",
|
|
"node_mode": birth_payload.get("node_mode") or "mean",
|
|
},
|
|
"target_year": int(target_year),
|
|
}
|
|
|
|
|
|
def _solar_return_local(pack: dict, tz: float) -> datetime | None:
|
|
data = _record(_record(pack.get("solar_return")).get("data"))
|
|
raw = data.get("dt_ut")
|
|
try:
|
|
moment = raw if isinstance(raw, datetime) else datetime.strptime(str(raw)[:19], "%Y-%m-%d %H:%M:%S")
|
|
except (TypeError, ValueError):
|
|
return None
|
|
return moment + timedelta(hours=float(tz))
|
|
|
|
|
|
def _mudda_segments(periods: list, start: datetime, end: datetime) -> list[dict]:
|
|
"""Calendar dates of the native Mudda Dasha segments.
|
|
|
|
`tajika.calc_mudda_dasha` gives each lord's share of the year in months
|
|
(Vimshottari proportion of 12). The year runs from this solar return to the
|
|
next one; each segment's length is its months / 12 of that span.
|
|
"""
|
|
span = end - start
|
|
cursor = start
|
|
rows: list[dict] = []
|
|
for period in periods:
|
|
if not isinstance(period, dict) or not isinstance(period.get("months"), (int, float)):
|
|
continue
|
|
finish = cursor + span * (float(period["months"]) / 12.0)
|
|
rows.append({
|
|
"lord": period.get("lord"),
|
|
"months": period.get("months"),
|
|
"start": cursor.strftime("%Y-%m-%d"),
|
|
"end": finish.strftime("%Y-%m-%d"),
|
|
})
|
|
cursor = finish
|
|
return rows
|
|
|
|
|
|
def build_annual_tajika(birth_payload: dict, chart: dict, *, reference: datetime) -> dict:
|
|
try:
|
|
from annual_tajika_pack import build_annual_tajika_pack
|
|
except ModuleNotFoundError: # pragma: no cover
|
|
from scripts.annual_tajika_pack import build_annual_tajika_pack
|
|
tz = float(birth_payload["tz"])
|
|
ref = reference.replace(tzinfo=None)
|
|
target = ref.year
|
|
pack = build_annual_tajika_pack(_annual_payload(birth_payload, target))
|
|
start = _solar_return_local(pack, tz)
|
|
if start is not None and start > ref:
|
|
target -= 1
|
|
pack = build_annual_tajika_pack(_annual_payload(birth_payload, target))
|
|
start = _solar_return_local(pack, tz)
|
|
following = build_annual_tajika_pack(_annual_payload(birth_payload, target + 1))
|
|
end = _solar_return_local(following, tz)
|
|
fields = {key: _record(pack.get(key)) for key in ("solar_return", "annual_chart", "muntha", "year_lord", "tajika_yogas", "mudda_dasha")}
|
|
producers = {key: field.get("status") for key, field in fields.items()}
|
|
periods = fields["mudda_dasha"].get("periods")
|
|
required = ("solar_return", "annual_chart", "muntha", "year_lord", "mudda_dasha")
|
|
unreliable = [key for key in required if producers.get(key) != "partial_verified"]
|
|
if start is None or end is None or end <= start:
|
|
unreliable.append("solar_return_dates")
|
|
if not isinstance(periods, list) or not periods:
|
|
unreliable.append("mudda_dasha_periods")
|
|
if unreliable:
|
|
return {
|
|
"status": "blocked",
|
|
"reason": "annual_pack_not_reliable:" + ",".join(dict.fromkeys(unreliable)),
|
|
"source": "annual_tajika_pack.build_annual_tajika_pack",
|
|
"producers": producers,
|
|
"boundary": "annual chart not attached; natal data is not a substitute",
|
|
}
|
|
annual_chart = _record(fields["annual_chart"].get("data"))
|
|
muntha = _record(fields["muntha"].get("data"))
|
|
year_lord = _record(fields["year_lord"].get("data")).get("year_lord")
|
|
annual_lagna_idx = _sign_index(annual_chart.get("asc_sign"))
|
|
focus = {"Sun", "Moon", year_lord}
|
|
aspects = []
|
|
for row in _record(fields["tajika_yogas"].get("data")).get("candidate_yogas") or []:
|
|
if not isinstance(row, dict):
|
|
continue
|
|
pair = row.get("planets") if isinstance(row.get("planets"), list) else []
|
|
if not focus.intersection(pair):
|
|
continue
|
|
aspects.append({
|
|
"name": row.get("name"),
|
|
"planets": pair,
|
|
"aspect": row.get("aspect"),
|
|
"motion": row.get("motion"),
|
|
})
|
|
return {
|
|
"status": "parameter_sensitive",
|
|
"source": "annual_tajika_pack.build_annual_tajika_pack (solar_return_full_report + cmd_tajika)",
|
|
"target_year": target,
|
|
"age": annual_chart.get("age"),
|
|
"varsha_year": {"start": start.strftime("%Y-%m-%d"), "end": end.strftime("%Y-%m-%d")},
|
|
"annual_lagna": annual_chart.get("asc_sign"),
|
|
"varshesha": year_lord,
|
|
"muntha": {
|
|
"sign": muntha.get("muntha_sign"),
|
|
"lord": muntha.get("muntha_lord"),
|
|
"house_in_annual_chart": _whole_sign_house(muntha.get("muntha_sign"), annual_lagna_idx),
|
|
"house_in_natal_chart": _whole_sign_house(muntha.get("muntha_sign"), _lagna_idx(chart)),
|
|
},
|
|
"mudda_dasha": _mudda_segments(periods, start, end),
|
|
"tajika_aspects": aspects,
|
|
"tajika_aspects_status": producers.get("tajika_yogas"),
|
|
"producers": producers,
|
|
"boundary": TAJIKA_BOUNDARY,
|
|
}
|
|
|
|
|
|
# ── entry point ─────────────────────────────────────────────────────────────
|
|
|
|
def _declared_routes(body: dict) -> set[str]:
|
|
values: list[str] = []
|
|
for key in ("strict_workflow_route", "theme", "themes"):
|
|
raw = body.get(key)
|
|
if isinstance(raw, str) and raw.strip():
|
|
values.append(raw.strip().lower())
|
|
elif isinstance(raw, list):
|
|
values.extend(str(item).strip().lower() for item in raw if item)
|
|
return {item for item in values if item}
|
|
|
|
|
|
def _guard(builder, *args, **kwargs) -> dict:
|
|
try:
|
|
result = builder(*args, **kwargs)
|
|
return result if isinstance(result, dict) else _blocked("producer_returned_non_dict")
|
|
except Exception as exc: # native producers stay isolated from the workflow
|
|
return _blocked(exc)
|
|
|
|
|
|
def attach_native_consultation_layers(chart: dict, *, birth_payload: dict, body: dict,
|
|
reference_dt: datetime) -> dict:
|
|
"""Write `chart.consultation_native_layers`. Safe on empty charts."""
|
|
if not isinstance(chart, dict) or not chart.get("success", True):
|
|
return chart
|
|
modules = _record(chart.get("modules"))
|
|
if not _record(chart.get("planets")) or not _record(chart.get("ascendant")):
|
|
chart["consultation_native_layers"] = {"schema": SCHEMA, **_blocked("chart_positions_missing")}
|
|
return chart
|
|
reference = reference_dt.replace(tzinfo=None)
|
|
routes = _declared_routes(body)
|
|
layers: dict[str, Any] = {"schema": SCHEMA, "status": "executed"}
|
|
try:
|
|
engine = _load_engine()
|
|
_scripts_on_path()
|
|
from ayanamsa_utils import temporary_ayanamsa
|
|
from domain_calculation_service import swiss_ephemeris_lock
|
|
tz = float(birth_payload.get("tz") or 0)
|
|
ayanamsa = str(birth_payload.get("ayanamsa") or "raman")
|
|
node_mode = str(birth_payload.get("node_mode") or "mean")
|
|
with swiss_ephemeris_lock(), temporary_ayanamsa(ayanamsa, engine.swe):
|
|
positions = _guard(_transit_positions, engine, reference, tz, node_mode)
|
|
saturn_lon = _record(positions.get("Saturn")).get("degree_raw")
|
|
layers["d9_summary"] = _guard(build_d9_summary, engine, chart, modules)
|
|
layers["punarphoo"] = _guard(build_punarphoo, chart, saturn_lon if isinstance(saturn_lon, (int, float)) else None)
|
|
layers["vivah_saham"] = _guard(build_vivah_saham, engine, chart)
|
|
layers["day_night"] = _guard(build_day_night, modules)
|
|
layers["slow_transits"] = _guard(
|
|
build_slow_transits, engine, chart, modules,
|
|
reference=reference, tz=tz, ayanamsa=ayanamsa, node_mode=node_mode,
|
|
)
|
|
layers["moon_transit"] = _guard(build_moon_transit, engine, chart, reference=reference, tz=tz, node_mode=node_mode)
|
|
layers["double_transit"] = _guard(
|
|
build_double_transit, engine, chart, modules,
|
|
birth_payload=birth_payload, reference=reference, transit_positions=positions,
|
|
)
|
|
layers["karakamsha"] = _guard(build_karakamsha, chart, modules)
|
|
layers["argala"] = _guard(build_argala, chart)
|
|
layers["dispositor_chains"] = _guard(build_dispositor_chains, engine, chart)
|
|
layers["inter_chart_linkage"] = _guard(build_inter_chart_linkage, engine, chart, modules)
|
|
if "annual" in routes:
|
|
layers["annual_tajika"] = _guard(build_annual_tajika, birth_payload, chart, reference=reference)
|
|
else:
|
|
layers["annual_tajika"] = {
|
|
"status": "not_applicable",
|
|
"reason": "annual pack is attached for the annual route only",
|
|
}
|
|
except Exception as exc:
|
|
layers = {"schema": SCHEMA, **_blocked(exc)}
|
|
chart["consultation_native_layers"] = layers
|
|
return chart
|