Files
Jyotisha/scripts/tajika_mudda.py
T
Jesse_ChenandClaude Opus 5.5 9d81f15afa 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
2026-10-03 21:09:31 +08:00

117 lines
5.1 KiB
Python

#!/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,
}