#!/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..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