feat(tajika): Mudda Dasha from natal Moon nakshatra + completed years, not the year lord (BUG-1216)

Ports calc_mudda_with_dasha_balance verbatim from upstream yinduzhanxing
c7117f92 (astrologer round-5 Q7). tajika_mudda.annual_mudda_dasha is the one
product entry for solar_return_full_report, cmd_tajika and full-reading
Step 9; periods are cropped to the Varsha year and carry months for the
month-window consumers. The consultation card re-anchors the engine's dates.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
This commit is contained in:
Jesse_Chen
2026-10-03 21:09:31 +08:00
co-authored by Claude Opus 5.5
parent 59bb7da385
commit 9d81f15afa
8 changed files with 513 additions and 49 deletions
+2
View File
@@ -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",
}
)
+23 -9
View File
@@ -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,
+55 -31
View File
@@ -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}")
+13 -6
View File
@@ -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)}
+114 -1
View File
@@ -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:
+116
View File
@@ -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,
}