diff --git a/scripts/annual_tajika_pack.py b/scripts/annual_tajika_pack.py index 75bc6ec2..3c00fcbd 100755 --- a/scripts/annual_tajika_pack.py +++ b/scripts/annual_tajika_pack.py @@ -326,6 +326,8 @@ def _monthly_windows(mudda_dasha: dict[str, Any]) -> list[dict[str, Any]]: "source": "mudda_dasha", "lord": period.get("lord"), "duration_months": period.get("months"), + "start": period.get("start"), + "end": period.get("end"), "status": "partial_verified", } ) diff --git a/scripts/consultation_native_layers.py b/scripts/consultation_native_layers.py index d8ec1a26..367e6bb4 100644 --- a/scripts/consultation_native_layers.py +++ b/scripts/consultation_native_layers.py @@ -866,27 +866,41 @@ def _solar_return_local(pack: dict, tz: float) -> datetime | None: return moment + timedelta(hours=float(tz)) -def _mudda_segments(periods: list, start: datetime, end: datetime) -> list[dict]: +def _mudda_segments(periods: list, start: datetime, end: datetime, anchor: Any = None) -> 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. + `tajika_mudda.annual_mudda_dasha` (BUG-1216: first lord from the natal + Moon nakshatra + completed years) gives each segment's start / end inside + the Varsha year, anchored at the solar-return instant `anchor`. They are + re-anchored on this consultation's year [start, end) and the last one is + cropped at the next solar return. Rows without dates fall back to the + months / 12 share of the span. """ + try: + origin = datetime.fromisoformat(str(anchor)) if anchor else None + except ValueError: + origin = None 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) + try: + seg_start = start + (datetime.fromisoformat(str(period["start"])) - origin) + seg_end = start + (datetime.fromisoformat(str(period["end"])) - origin) + except (KeyError, TypeError, ValueError): + seg_start, seg_end = cursor, cursor + span * (float(period["months"]) / 12.0) + seg_end = min(seg_end, end) + if seg_end <= seg_start: + continue rows.append({ "lord": period.get("lord"), "months": period.get("months"), - "start": cursor.strftime("%Y-%m-%d"), - "end": finish.strftime("%Y-%m-%d"), + "start": seg_start.strftime("%Y-%m-%d"), + "end": seg_end.strftime("%Y-%m-%d"), }) - cursor = finish + cursor = seg_end return rows @@ -957,7 +971,7 @@ def build_annual_tajika(birth_payload: dict, chart: dict, *, reference: datetime "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), + "mudda_dasha": _mudda_segments(periods, start, end, _record(fields["mudda_dasha"].get("data")).get("annual_start")), "tajika_aspects": aspects, "tajika_aspects_status": producers.get("tajika_yogas"), "producers": producers, diff --git a/scripts/jyotish_engine.py b/scripts/jyotish_engine.py index cab665b8..559b0c17 100644 --- a/scripts/jyotish_engine.py +++ b/scripts/jyotish_engine.py @@ -14605,56 +14605,84 @@ def cmd_tajika(args): chart, asc_idx, jd, ayanamsa = _compute_chart_from_args(args) if chart is None: return {"error": "swisseph未安装"} - try: - sys.path.insert(0, SCRIPT_DIR) - from tajika import calc_muntha, calc_year_lord, calc_mudda_dasha, calc_tri_pataka - except ImportError as e: - return {"error": f"tajika模块导入失败: {e}"} planets = chart.get('planets', {}) planet_lons = {} for pn, pd in planets.items(): if isinstance(pd, dict) and 'degree' in pd: planet_lons[pn] = pd['degree'] asc_deg = chart.get('ascendant', {}).get('lon', chart.get('ascendant', {}).get('degree', 0)) - asc_si = int(asc_deg / 30) % 12 # sign index age = args.age if age is None: return {"error": "请提供 --age 参数(当前年龄)"} + return _tajika_annual_core(args, planet_lons, int(asc_deg / 30) % 12, age, args.mode or 'all') + + +def _tajika_annual_core(args, planet_lons, asc_si, age, mode='all'): + """Muntha / year lord / Mudda / Tri-Pataka for cmd_tajika and full reading Step 9. + + Year lord: Panchadhikari on the solar-return chart (BUG-1214). Mudda: + natal Moon nakshatra + completed years (BUG-1216), via the same + tajika_mudda helper as solar_return_full_report. + """ + try: + sys.path.insert(0, SCRIPT_DIR) + from tajika import calc_muntha, calc_year_lord, calc_tri_pataka + from tajika_mudda import annual_mudda_dasha + except ImportError as e: + return {"error": f"tajika模块导入失败: {e}"} + + sr_cache = {} + + def _solar_return(): + if 'sr' not in sr_cache: + from cmd_solar_return import solar_return_location_kwargs + from solar_return import calc_solar_return_chart + from ayanamsa_utils import DEFAULT_AYANAMSA_NAME + sr_cache['ayanamsa'] = getattr(args, 'ayanamsa', None) or DEFAULT_AYANAMSA_NAME + sr_cache['sr'] = calc_solar_return_chart( + int(args.year), int(args.month), int(args.day), int(args.hour), int(args.minute), + float(args.lat), float(args.lon), float(args.tz), int(args.year) + int(age), + ayanamsa_name=sr_cache['ayanamsa'], **solar_return_location_kwargs(args), + ) + return sr_cache['sr'] + + def _sr_usable(sr) -> bool: + return isinstance(sr, dict) and not sr.get('error') and sr.get('birth_asc_sign_idx') is not None def _year_lord() -> Dict: # Panchadhikari on the solar-return chart, same helper as # solar_return_full_report (BUG-1214); Muntha lord only when the # annual chart cannot be computed (recorded as fallback, not hidden). - from cmd_solar_return import solar_return_location_kwargs - from solar_return import calc_solar_return_chart from tajika_year_lord import select_annual_year_lord - from ayanamsa_utils import DEFAULT_AYANAMSA_NAME - ayanamsa_name = getattr(args, 'ayanamsa', None) or DEFAULT_AYANAMSA_NAME - sr = calc_solar_return_chart( - int(args.year), int(args.month), int(args.day), int(args.hour), int(args.minute), - float(args.lat), float(args.lon), float(args.tz), int(args.year) + int(age), - ayanamsa_name=ayanamsa_name, **solar_return_location_kwargs(args), - ) - if not isinstance(sr, dict) or sr.get('error') or sr.get('birth_asc_sign_idx') is None: + sr = _solar_return() + if not _sr_usable(sr): fallback = calc_year_lord(asc_si, age) fallback['selection_basis'] = 'fallback_muntha_sign_lord_after_panchadhikari_block' fallback['fallback_reason'] = (sr or {}).get('error', 'solar_return_chart_unavailable') if isinstance(sr, dict) else 'solar_return_chart_unavailable' return fallback return select_annual_year_lord( sr, natal_asc_sign_idx=sr['birth_asc_sign_idx'], completed_years=int(age), - ayanamsa_name=ayanamsa_name, birth_lat=float(args.lat), birth_lon=float(args.lon), birth_tz=float(args.tz), + ayanamsa_name=sr_cache['ayanamsa'], birth_lat=float(args.lat), birth_lon=float(args.lon), birth_tz=float(args.tz), + ) + + def _mudda() -> Dict: + sr = _solar_return() + if not _sr_usable(sr): + return {'status': 'blocked', 'reason': 'solar_return_chart_required_for_mudda'} + return annual_mudda_dasha( + annual_start=(sr.get('solar_return') or {}).get('dt_local'), + completed_years=int(age), + natal_moon_longitude=sr.get('birth_moon_longitude'), ) result = {} - mode = args.mode or 'all' if mode in ('all', 'muntha'): result['muntha'] = calc_muntha(asc_si, age) - year_lord = _year_lord() if mode in ('all', 'yearlord', 'mudda', 'tripataka') else None + year_lord = _year_lord() if mode in ('all', 'yearlord', 'tripataka') else None if mode in ('all', 'yearlord'): result['year_lord'] = year_lord if mode in ('all', 'mudda'): - varsha_lord = year_lord.get('year_lord', 'Jupiter') - result['mudda_dasha'] = calc_mudda_dasha(asc_si, varsha_lord, args.month) + result['mudda_dasha'] = _mudda() if mode in ('all', 'tripataka'): varsha_lord = year_lord.get('year_lord', 'Jupiter') muntha_si = (asc_si + age) % 12 @@ -16405,16 +16433,12 @@ def cmd_full_reading(args): if age is not None: try: - from tajika import calc_muntha, calc_year_lord, calc_mudda_dasha, calc_tri_pataka - asc_si = int(asc_deg / 30) % 12 - tajika_result = {} - tajika_result['muntha'] = calc_muntha(asc_si, age) - yl = calc_year_lord(asc_si, age) - tajika_result['year_lord'] = yl - varsha_lord = yl.get('year_lord', 'Jupiter') - tajika_result['mudda_dasha'] = calc_mudda_dasha(asc_si, varsha_lord, args.month) - muntha_si = (asc_si + age) % 12 - tajika_result['tri_pataka'] = calc_tri_pataka(planet_lons, varsha_lord, muntha_si) + # Same core as cmd_tajika (BUG-1216): Panchadhikari year lord and + # Mudda from the natal Moon nakshatra, not calc_year_lord / + # calc_mudda_dasha (Muntha lord, year-lord-started Mudda). + tajika_result = _tajika_annual_core(args, planet_lons, int(asc_deg / 30) % 12, age) + if tajika_result.get('error'): + raise RuntimeError(tajika_result['error']) report['modules']['tajika'] = tajika_result except Exception as e: report['errors'].append(f"tajika: {e}") diff --git a/scripts/solar_return.py b/scripts/solar_return.py index 3d7ceca9..86cfdee1 100644 --- a/scripts/solar_return.py +++ b/scripts/solar_return.py @@ -379,6 +379,10 @@ def calc_solar_return_chart( sun_data = birth_chart.get('planets', {}).get('Sun', {}) birth_sun_lon = sun_data.get('degree_raw', sun_data.get('degree', 0)) + # Natal Moon for Mudda Dasha (BUG-1216: first lord from the natal + # Moon nakshatra, astrologer round-5 Q7). + moon_data = birth_chart.get('planets', {}).get('Moon', {}) + birth_moon_lon = moon_data.get('degree_raw', moon_data.get('degree')) if isinstance(moon_data, dict) else None birth_jd_ut = birth_jd # Step 2: 计算太阳返照精确时刻 @@ -435,6 +439,7 @@ def calc_solar_return_chart( 'ascendant': ascendant, 'asc_sign_idx': sr_asc_idx, 'birth_asc_sign_idx': birth_asc_idx, + 'birth_moon_longitude': birth_moon_lon, 'target_year': target_year, 'birth_year': birth_year, 'annual_location': {'latitude': annual_lat, 'longitude': annual_lon, @@ -619,16 +624,18 @@ def solar_return_full_report( except Exception as e: result['tri_pataka'] = {'error': str(e)} - # 7. Mudda Dasha(需 asc_sign_idx + year_lord,降级模式跳过) + # 7. Mudda Dasha:出生月宿 + 已满年数起运,与年主无关(占星师第五轮问 7, + # BUG-1216)。与 cmd_tajika 共用 tajika_mudda.annual_mudda_dasha。降级模式跳过。 if degraded: result['mudda_dasha'] = {'error': 'swisseph未安装,无法计算Mudda Dasha(需完整星盘)'} else: try: - from tajika import calc_mudda_dasha - yl_data = result.get('year_lord', {}) - yl = yl_data.get('year_lord', '') - mudda = calc_mudda_dasha(asc_sign_idx, yl, birth_month or 1) - result['mudda_dasha'] = mudda + from tajika_mudda import annual_mudda_dasha + result['mudda_dasha'] = annual_mudda_dasha( + annual_start=sr['solar_return'].get('dt_local'), + completed_years=age, + natal_moon_longitude=sr.get('birth_moon_longitude'), + ) except Exception as e: result['mudda_dasha'] = {'error': str(e)} diff --git a/scripts/tajika.py b/scripts/tajika.py index b5099bf5..590476af 100644 --- a/scripts/tajika.py +++ b/scripts/tajika.py @@ -13,7 +13,7 @@ Tajika/Varshaphala年运盘模块 v1.0 -年度Lord(Year Lord) """ from typing import Dict, List, Optional -from datetime import datetime +from datetime import datetime, timedelta import math import json import os @@ -355,6 +355,119 @@ def calc_mudda_dasha(asc_sign_idx: int, } +# Ported verbatim from upstream yinduzhanxing c7117f92 (BUG-1216): Mudda from +# completed years + natal Moon nakshatra (astrologer round-5 Q7), not the year lord. +def calc_mudda_with_dasha_balance( + *, + annual_start: datetime, + completed_years: int, + natal_moon_longitude: float, + balance_moon_longitude: float, + balance_profile: str, + annual_duration_days: float = 365.25, +) -> Dict: + """Produce the two Moon-balance Mudda profiles used by annual reports. + + The operating Mudda lord is selected from completed years and the natal + Moon's nakshatra. The balance is then calculated from either the natal + Moon or the Varshaphala Moon, and the running period is cropped at the + true solar-return instant. This keeps p132 and p133 as distinct + producers rather than treating a different balance source as a label. + """ + + dasha_order = ["Ketu", "Venus", "Sun", "Moon", "Mars", "Rahu", "Jupiter", "Saturn", "Mercury"] + dasha_years = {"Ketu": 7, "Venus": 20, "Sun": 6, "Moon": 10, "Mars": 7, "Rahu": 18, "Jupiter": 16, "Saturn": 19, "Mercury": 17} + valid_profiles = {"moon_at_birth_time", "moon_in_varshaphala"} + if not isinstance(annual_start, datetime): + return {"status": "blocked", "reason": "true_solar_return_datetime_required_for_mudda_balance", "balance_profile": balance_profile} + if balance_profile not in valid_profiles: + return {"status": "blocked", "reason": "recognized_moon_balance_profile_required", "balance_profile": balance_profile} + try: + natal_moon_longitude = float(natal_moon_longitude) % 360.0 + balance_moon_longitude = float(balance_moon_longitude) % 360.0 + annual_duration_days = float(annual_duration_days) + except (TypeError, ValueError): + return {"status": "blocked", "reason": "sidereal_natal_and_balance_moon_longitudes_required", "balance_profile": balance_profile} + if annual_duration_days <= 0: + return {"status": "blocked", "reason": "positive_annual_duration_days_required", "balance_profile": balance_profile} + + nakshatra_span = 360.0 / 27.0 + natal_nakshatra_number = int(natal_moon_longitude / nakshatra_span) + 1 + remainder = (int(completed_years) + natal_nakshatra_number - 2) % 9 + remainder_to_lord = {1: "Sun", 2: "Moon", 3: "Mars", 4: "Rahu", 5: "Jupiter", 6: "Saturn", 7: "Mercury", 8: "Ketu", 0: "Venus"} + starting_lord = remainder_to_lord[remainder] + balance_fraction_remaining = 1.0 - ((balance_moon_longitude % nakshatra_span) / nakshatra_span) + starting_duration = dasha_years[starting_lord] * annual_duration_days / 120.0 + remaining_starting_duration = starting_duration * balance_fraction_remaining + elapsed_starting_duration = starting_duration - remaining_starting_duration + full_start = annual_start - timedelta(days=elapsed_starting_duration) + annual_end = annual_start + timedelta(days=annual_duration_days) + + periods = [] + antardasha_periods = [] + cursor = full_start + start_idx = dasha_order.index(starting_lord) + # The controlled parity sample displays the running remainder plus the next full recurrence of the + # same lord, so this surface has ten visible groups rather than truncating + # at the civil 365.25-day boundary. + for order in range(10): + lord = dasha_order[(start_idx + order) % len(dasha_order)] + duration_days = dasha_years[lord] * annual_duration_days / 120.0 + end = cursor + timedelta(days=duration_days) + visible_start = max(cursor, annual_start) + visible_end = end + if visible_start < visible_end: + periods.append({ + "order": len(periods) + 1, + "lord": lord, + "duration_days": (visible_end - visible_start).total_seconds() / 86400.0, + "full_duration_days": duration_days, + "start": visible_start.isoformat(sep=" "), + "end": visible_end.isoformat(sep=" "), + }) + sub_cursor = cursor + sub_start_idx = dasha_order.index(lord) + for sub_order in range(9): + sub_lord = dasha_order[(sub_start_idx + sub_order) % len(dasha_order)] + sub_duration_days = duration_days * dasha_years[sub_lord] / 120.0 + sub_end = end if sub_order == 8 else sub_cursor + timedelta(days=sub_duration_days) + visible_sub_start = max(sub_cursor, annual_start) + visible_sub_end = sub_end + if visible_sub_start < visible_sub_end: + antardasha_periods.append({ + "main_order": order + 1, + "sub_order": sub_order + 1, + "main_lord": lord, + "sub_lord": sub_lord, + "duration_days": (visible_sub_end - visible_sub_start).total_seconds() / 86400.0, + "start": visible_sub_start.isoformat(sep=" "), + "end": visible_sub_end.isoformat(sep=" "), + }) + sub_cursor = sub_end + cursor = end + + return { + "status": "parameter_sensitive", + "balance_profile": balance_profile, + "boundary_semantics": "dasha_beginning_dates", + "annual_start": annual_start.isoformat(sep=" "), + "annual_end": annual_end.isoformat(sep=" "), + "schedule_end": cursor.isoformat(sep=" "), + "annual_duration_days": annual_duration_days, + "starting_lord": starting_lord, + "starting_lord_basis": "completed_years_plus_natal_moon_nakshatra_minus_two_mod_nine", + "completed_years": int(completed_years), + "natal_moon_longitude": natal_moon_longitude, + "natal_moon_nakshatra_number": natal_nakshatra_number, + "balance_moon_longitude": balance_moon_longitude, + "balance_fraction_remaining": balance_fraction_remaining, + "periods": periods, + "antardasha_periods": antardasha_periods, + "claim_boundary": "Native Moon-balance Mudda follows the documented annual starting-lord and proportional balance rule. Date parity remains a controlled-sample comparison.", + } + + + def calc_tri_pataka(planet_lons: Dict[str, float], varsha_lord: str, muntha_sign_idx: int) -> Dict: diff --git a/scripts/tajika_mudda.py b/scripts/tajika_mudda.py new file mode 100644 index 00000000..8372f9ee --- /dev/null +++ b/scripts/tajika_mudda.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Annual Mudda Dasha from the natal Moon nakshatra (BUG-1216). + +Astrologer round-5 ruling 2026-10-03, Q7: the first Mudda lord is +(completed years + natal Moon nakshatra number 1..27 - 2) mod 9, remainder +1 Sun, 2 Moon, 3 Mars, 4 Rahu, 5 Jupiter, 6 Saturn, 7 Mercury, 8 Ketu, +0 Venus. It does not depend on the year lord. + +The rule itself is `tajika.calc_mudda_with_dasha_balance`, ported verbatim +from upstream yinduzhanxing c7117f92. This module is the single product +entry both annual producers (`solar_return.solar_return_full_report` and +`jyotish_engine.cmd_tajika`) call, so `annual_tajika_pack` compares two runs +of the same rule. What it adds on top of the upstream function: + +- the balance comes from the natal Moon (`moon_at_birth_time`); +- the year length is named: `solar_year_365_25` (product default, the + upstream default) or `savana_360` (360-day year, research only); +- `periods` are cropped to the annual window [annual_start, annual_end) and + carry `months` (share of 12) for the existing month-window consumers; the + upstream ten-group surface (the last group runs past the year end) stays + in `periods_full`. Upstream's sub-periods are not carried (no product + surface shows them). + +The legacy `tajika.calc_mudda_dasha` (first lord = year lord) is no longer +called from any product path. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Dict, Optional + +try: # scripts/ on sys.path + from tajika import calc_mudda_with_dasha_balance +except ImportError: # pragma: no cover - package import + from scripts.tajika import calc_mudda_with_dasha_balance + +BALANCE_PROFILE = "moon_at_birth_time" +YEAR_LENGTHS = {"solar_year_365_25": 365.25, "savana_360": 360.0} +DEFAULT_YEAR_LENGTH = "solar_year_365_25" +SOURCE = "tajika.calc_mudda_with_dasha_balance (upstream yinduzhanxing c7117f92)" +RULE = "astrologer_round5_q7_completed_years_plus_natal_nakshatra_minus_two_mod_nine" +RULE_TEXT = "起运星 =(已满年数 + 出生月宿序号 − 2)mod 9,与年主无关" +RULE_TEXT_EN = "First lord = (completed years + natal Moon nakshatra number - 2) mod 9; independent of the year lord" + + +def _parse(value: str) -> datetime: + return datetime.fromisoformat(str(value)) + + +def annual_mudda_dasha( + *, + annual_start: Optional[datetime], + completed_years: Optional[int], + natal_moon_longitude: Optional[float], + year_length: str = DEFAULT_YEAR_LENGTH, +) -> Dict[str, Any]: + """Mudda Dasha for one Varsha year, anchored at the solar-return instant.""" + if year_length not in YEAR_LENGTHS: + return {"status": "blocked", "reason": "unknown_year_length", "year_length_basis": year_length} + if completed_years is None or natal_moon_longitude is None: + return {"status": "blocked", "reason": "completed_years_and_natal_moon_required_for_mudda", + "balance_profile": BALANCE_PROFILE} + raw = calc_mudda_with_dasha_balance( + annual_start=annual_start, + completed_years=int(completed_years), + natal_moon_longitude=natal_moon_longitude, + balance_moon_longitude=natal_moon_longitude, + balance_profile=BALANCE_PROFILE, + annual_duration_days=YEAR_LENGTHS[year_length], + ) + if raw.get("status") == "blocked": + return {**raw, "year_length_basis": year_length, "source": SOURCE} + start = _parse(raw["annual_start"]) + end = _parse(raw["annual_end"]) + year_days = float(raw["annual_duration_days"]) + periods = [] + for row in raw.get("periods") or []: + p_start = max(_parse(row["start"]), start) + p_end = min(_parse(row["end"]), end) + if p_start >= p_end: + continue + days = (p_end - p_start).total_seconds() / 86400.0 + periods.append({ + "order": len(periods) + 1, + "lord": row["lord"], + "months": round(days / year_days * 12.0, 2), + "duration_days": days, + "full_duration_days": row.get("full_duration_days"), + "start": p_start.isoformat(sep=" "), + "end": p_end.isoformat(sep=" "), + }) + return { + # The first-lord rule is the astrologer's round-5 ruling; dates are + # not externally replayed (upstream keeps its own parameter_sensitive). + "status": "partial_verified", + "upstream_status": raw.get("status"), + "starting_lord": raw.get("starting_lord"), + "starting_lord_basis": raw.get("starting_lord_basis"), + "rule": RULE, + "rule_text": RULE_TEXT, + "rule_text_en": RULE_TEXT_EN, + "annual_start": raw.get("annual_start"), + "annual_end": raw.get("annual_end"), + "annual_duration_days": year_days, + "year_length_basis": year_length, + "completed_years": raw.get("completed_years"), + "natal_moon_longitude": raw.get("natal_moon_longitude"), + "natal_moon_nakshatra_number": raw.get("natal_moon_nakshatra_number"), + "balance_profile": raw.get("balance_profile"), + "balance_fraction_remaining": raw.get("balance_fraction_remaining"), + "periods": periods, + "periods_full": raw.get("periods"), + "independent_of_year_lord": True, + "source": SOURCE, + } diff --git a/tests/test_tajika_mudda.py b/tests/test_tajika_mudda.py new file mode 100644 index 00000000..6d74dd0e --- /dev/null +++ b/tests/test_tajika_mudda.py @@ -0,0 +1,185 @@ +"""Mudda Dasha from the natal Moon nakshatra, independent of the year lord (BUG-1216). + +Astrologer round-5 ruling 2026-10-03, Q7: first lord = (completed years + +natal Moon nakshatra number 1..27 - 2) mod 9; remainder 1 Sun, 2 Moon, +3 Mars, 4 Rahu, 5 Jupiter, 6 Saturn, 7 Mercury, 8 Ketu, 0 Venus. +""" + +from __future__ import annotations + +import ast +import hashlib +import inspect +import subprocess +import sys +from datetime import datetime +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +for entry in (str(ROOT), str(ROOT / "scripts")): + if entry not in sys.path: + sys.path.insert(0, entry) + +from scripts import tajika # noqa: E402 +from scripts.tajika_mudda import annual_mudda_dasha # noqa: E402 + +REMAINDER_TO_LORD = {1: "Sun", 2: "Moon", 3: "Mars", 4: "Rahu", 5: "Jupiter", 6: "Saturn", 7: "Mercury", 8: "Ketu", 0: "Venus"} +VIMSHOTTARI = ["Ketu", "Venus", "Sun", "Moon", "Mars", "Rahu", "Jupiter", "Saturn", "Mercury"] +NAK = 360.0 / 27.0 +START = datetime(2026, 3, 1, 10, 30, 0) +UPSTREAM = Path("/workspace/yinduzhanxing") +UPSTREAM_SHA = "c7117f92" + + +@pytest.mark.parametrize("years", [0, 1, 7, 26, 45, 80]) +@pytest.mark.parametrize("nakshatra", list(range(1, 28))) +def test_first_lord_follows_the_round5_formula(years: int, nakshatra: int) -> None: + moon = (nakshatra - 1) * NAK + NAK * 0.4 + result = annual_mudda_dasha(annual_start=START, completed_years=years, natal_moon_longitude=moon) + expected = REMAINDER_TO_LORD[(years + nakshatra - 2) % 9] + assert result["status"] == "partial_verified" + assert result["natal_moon_nakshatra_number"] == nakshatra + assert result["starting_lord"] == expected + assert result["periods"][0]["lord"] == expected + + +def test_record_fields_and_year_window() -> None: + moon = 3 * NAK + NAK * 0.25 # nakshatra 4, 75% of the first lord left + result = annual_mudda_dasha(annual_start=START, completed_years=30, natal_moon_longitude=moon) + for key in ("annual_start", "annual_end", "annual_duration_days", "completed_years", + "natal_moon_longitude", "balance_profile", "year_length_basis"): + assert key in result, key + assert result["balance_profile"] == "moon_at_birth_time" + assert result["year_length_basis"] == "solar_year_365_25" + assert result["annual_duration_days"] == 365.25 + assert result["independent_of_year_lord"] is True + periods = result["periods"] + # Nine lords in Vimshottari order, cropped to the year: the running + # remainder of the first lord, eight full lords, then the first lord again + # up to the year end. + first = VIMSHOTTARI.index(result["starting_lord"]) + assert [row["lord"] for row in periods] == [VIMSHOTTARI[(first + i) % 9] for i in range(10)] + assert periods[0]["start"] == START.isoformat(sep=" ") + assert periods[-1]["end"] == result["annual_end"] + assert sum(row["months"] for row in periods) == pytest.approx(12.0, abs=0.05) + assert periods[0]["duration_days"] == pytest.approx(periods[0]["full_duration_days"] * 0.75, rel=1e-9) + for left, right in zip(periods, periods[1:]): + assert left["end"] == right["start"] + + +def test_360_day_year_is_named_separately() -> None: + solar = annual_mudda_dasha(annual_start=START, completed_years=30, natal_moon_longitude=100.0) + savana = annual_mudda_dasha(annual_start=START, completed_years=30, natal_moon_longitude=100.0, year_length="savana_360") + assert savana["year_length_basis"] == "savana_360" + assert savana["annual_duration_days"] == 360.0 + assert savana["starting_lord"] == solar["starting_lord"] + assert savana["annual_end"] != solar["annual_end"] + assert annual_mudda_dasha(annual_start=START, completed_years=30, natal_moon_longitude=100.0, year_length="x")["status"] == "blocked" + + +def test_blocked_without_inputs() -> None: + assert annual_mudda_dasha(annual_start=None, completed_years=3, natal_moon_longitude=10.0)["status"] == "blocked" + assert annual_mudda_dasha(annual_start=START, completed_years=None, natal_moon_longitude=10.0)["status"] == "blocked" + assert annual_mudda_dasha(annual_start=START, completed_years=3, natal_moon_longitude=None)["status"] == "blocked" + + +FICTIONAL = {"birth": {"date": "1990-01-01", "time": "12:00:00", "latitude": 39.9, "longitude": 116.4, "utc_offset": "+08:00"}, + "settings": {"ayanamsa": "raman"}} + + +def test_changing_only_the_year_lord_leaves_mudda_unchanged(monkeypatch) -> None: + """Natal nakshatra, completed years, year start, year length and balance + mode fixed; only the year lord changes -> the Mudda table is identical.""" + import tajika_year_lord as year_lord_module # the name solar_return imports + from scripts.solar_return import solar_return_full_report + + def run(forced_lord): + real = year_lord_module.select_annual_year_lord + + def fake(sr, **kwargs): + data = real(sr, **kwargs) + if forced_lord: + data["year_lord"] = forced_lord + return data + + monkeypatch.setattr(year_lord_module, "select_annual_year_lord", fake) + return solar_return_full_report(1990, 1, 1, 12, 0, 39.9, 116.4, 8.0, 2026, ayanamsa_name="raman") + + baseline = run(None) + tables = [] + for lord in ("Sun", "Saturn", "Venus"): + report = run(lord) + assert report["year_lord"]["year_lord"] == lord + tables.append(report["mudda_dasha"]) + assert baseline["mudda_dasha"]["status"] == "partial_verified" + for table in tables: + assert table == baseline["mudda_dasha"] + + +def test_both_producers_give_the_same_mudda_and_the_card_uses_its_dates() -> None: + from scripts.annual_tajika_pack import build_annual_tajika_pack + from scripts.consultation_native_layers import _mudda_segments + + pack = build_annual_tajika_pack({**FICTIONAL, "target_year": 2026}) + field = pack["mudda_dasha"] + assert field["status"] == "partial_verified" + values = [row.get("value") or row.get("data") for row in field["producers_checked"]] + data = field["data"] + assert data["independent_of_year_lord"] is True + windows = pack["monthly_windows"] + assert [row["lord"] for row in windows] == [row["lord"] for row in data["periods"]] + assert all(row["start"] and row["end"] for row in windows) + start = datetime.fromisoformat(data["annual_start"]) + end = datetime.fromisoformat(data["annual_end"]) + rows = _mudda_segments(data["periods"], start, end, data["annual_start"]) + assert [row["lord"] for row in rows] == [row["lord"] for row in data["periods"]] + assert rows[0]["start"] == start.strftime("%Y-%m-%d") + assert rows[-1]["end"] == end.strftime("%Y-%m-%d") + assert values # producers recorded + + +def test_cmd_tajika_mudda_matches_the_solar_return_producer() -> None: + from types import SimpleNamespace + + from scripts.jyotish_engine import cmd_tajika + from scripts.solar_return import solar_return_full_report + + args = SimpleNamespace(year=1990, month=1, day=1, hour=12, minute=0, second=0, lat=39.9, lon=116.4, tz=8.0, + age=36, mode="all", ayanamsa="raman", node_mode="mean") + tajika_report = cmd_tajika(args) + solar = solar_return_full_report(1990, 1, 1, 12, 0, 39.9, 116.4, 8.0, 2026, ayanamsa_name="raman") + assert tajika_report["mudda_dasha"] == solar["mudda_dasha"] + + +def test_no_product_path_calls_the_year_lord_started_mudda() -> None: + callers = [path.name for path in sorted((ROOT / "scripts").glob("*.py")) + if path.name != "tajika.py" and "calc_mudda_dasha(" in path.read_text(encoding="utf-8")] + assert callers == [] + + +def _function_source(source: str, name: str) -> str: + tree = ast.parse(source) + node = next(n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name == name) + lines = source.splitlines() + return "\n".join(lines[node.lineno - 1:node.end_lineno]) + + +# sha256 of the function text as ported from upstream c7117f92. +PORTED_SHA256 = { + "calc_mudda_with_dasha_balance": "8569dd862cf575ac8a670115206c68cd79c5b51bf53113c07bd0e21b07f0560f", +} + + +@pytest.mark.parametrize("name", sorted(PORTED_SHA256)) +def test_ported_function_is_verbatim(name: str) -> None: + ours = _function_source(inspect.getsource(tajika), name) + assert hashlib.sha256(ours.encode("utf-8")).hexdigest() == PORTED_SHA256[name] + if not (UPSTREAM / ".git").exists(): + pytest.skip("upstream checkout not on this machine") + upstream = subprocess.run(["git", "show", f"{UPSTREAM_SHA}:scripts/tajika.py"], cwd=UPSTREAM, + capture_output=True, text=True, check=False) + if upstream.returncode != 0: + pytest.skip("upstream commit not available") + assert ours == _function_source(upstream.stdout, name) diff --git a/tests/test_tajika_panchadhikari_year_lord.py b/tests/test_tajika_panchadhikari_year_lord.py index 3211c1ca..7efc3ce5 100644 --- a/tests/test_tajika_panchadhikari_year_lord.py +++ b/tests/test_tajika_panchadhikari_year_lord.py @@ -132,8 +132,11 @@ def test_both_annual_producers_select_the_same_year_lord(target_year: int) -> No assert sum(row["selected"] for row in value["candidate_list"]) == 1 assert values[0]["candidates"] == values[1]["candidates"] assert values[0]["year_lord"] == values[1]["year_lord"] - # Mudda Dasha starts from the selected year lord on both lanes. - assert pack["mudda_dasha"]["data"]["varsha_lord"] == values[0]["year_lord"] + # BUG-1216 (round-5 Q7): Mudda no longer starts from the year lord; it + # starts from completed years + natal Moon nakshatra (tests/test_tajika_mudda.py). + mudda = pack["mudda_dasha"]["data"] + assert mudda["independent_of_year_lord"] is True + assert mudda["starting_lord_basis"] == "completed_years_plus_natal_moon_nakshatra_minus_two_mod_nine" def test_pyjhora_reference_runs_only_on_the_research_opt_in(monkeypatch) -> None: