Files
Jyotisha/scripts/tajika_mudda.py
ClaudeandClaude Opus 5.5 5d64f71b81 fix: astrologer round-5 rulings 2/3/4/5/10/13 (BUG-1746..1752)
- Functional default = PVR Table 30 (functional_profile=pvr_table_30); the former
  product table is bphs_exception_profile, scored only when named (rectification
  names it, scores unchanged). profile_divergence shows both roles side by side;
  movable-lagna Moon by the table's paksha note; dual-lordship note; prior rulings
  BUG-1570/1590/#166 Q4 checked against Table 30.
- Shadbala: six open items labelled parameter_sensitive; every option combination
  computed; only planets whose pass/fail flips get minimum_check=parameter_sensitive.
- Vimshottari AD/PD rows carry parent_period_start / visible_period_start; the
  consult card cuts the balance MD from its full parent and clips at birth.
- KP DBA: full first parent, clipped at birth (no balance rescale).
- Mudda: balance fields + balance_method (angle default, time comparison apart),
  nominal_year_days, her crossing wording; Harsha combined blocked reason lists items.
- D30 _meta describes the unequal parts (as her r40).
- Golden recaptured with TTL=0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
2026-11-08 11:57:22 +08:00

399 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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);
- three intervals are kept apart (BUG-1722, astrologer r40 review item 4):
(a) `raw_periods` / `raw_antardasha_periods`: upstream's full ten-group
timeline, never truncated (the last group runs past the next return
on purpose; `periods_full` is the same list under its old name);
(b) `nominal_year`: the year length the dasha uses (365.25 d) and its end
(`annual_end`);
(c) `coverage_interval`: the report's year, actual solar return -> next
actual solar return, when the caller passes `coverage_end`.
`periods` / `antardasha_periods` are only a view: the raw rows clipped to
(c) (or to (b) when the next return is unknown), labelled in
`periods_view`, and carry `months` (share of 12) for the month-window
consumers. 原值:视图裁在 (b) 的 annual_end,比下一次真实返照早 5–15 分钟,
留下空档。Day rows are not added here.
`annual_mudda_profiles` (产品 2026-11-07 与占星师上游年运同步) adds upstream's
two other balance conventions as labelled alternatives next to the default:
`moon_in_varshaphala` (same first lord, balance from the annual-chart Moon)
and `without_dasha_balance` (upstream's PL9 p135 / pl9-export profile: no
balance, first lord = the Varsha lord). The default stays
`moon_at_birth_time` because the astrologer's round-5 Q7 ruling (BUG-1216)
fixes the first lord from the natal nakshatra, independent of the year lord.
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, calc_mudda_without_dasha_balance
except ImportError: # pragma: no cover - package import
from scripts.tajika import calc_mudda_with_dasha_balance, calc_mudda_without_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))
# ── 占星师 2026-11-08 第五轮裁定 2:余额字段与名义年 ─────────────────────────────────
NOMINAL_YEAR_DAYS_NOTE = ("Mudda 的名义年长;不等于两次真实太阳回归之间的间隔"
"(实际约 365.2535–365.2608 天,名义年末提前约 302–930 秒)")
BALANCE_METHOD_ANGLE = "nakshatra_arc_angle"
BALANCE_METHOD_TIME = "actual_elapsed_time"
NAKSHATRA_SPAN = 360.0 / 27.0
def balance_block(start_planet: Any, balance_moon_longitude: Any) -> Dict[str, Any]:
"""Default traditional balance: the Moon's arc already covered in its nakshatra (angle based)."""
try:
lon = float(balance_moon_longitude) % 360.0
except (TypeError, ValueError):
return {"status": "blocked", "reason": "balance_moon_longitude_required", "balance_method": BALANCE_METHOD_ANGLE}
elapsed = (lon % NAKSHATRA_SPAN) / NAKSHATRA_SPAN
return {
"status": "computed",
"balance_method": BALANCE_METHOD_ANGLE,
"is_default": True,
"start_planet": start_planet,
"elapsed_fraction": elapsed,
"remaining_fraction": 1.0 - elapsed,
"basis": "月亮在所在月宿内已走过的角度 ÷ 13°20′(默认传统算法)",
}
def time_balance_comparison(start_planet: Any, birth_jd_ut: Any, natal_moon_longitude: Any,
ayanamsa_name: Any = None) -> Dict[str, Any]:
"""Separate comparison: the share of the Moon's actual time in the nakshatra already elapsed at birth.
Never used for any date; never merged with the angle balance.
"""
base = {"balance_method": BALANCE_METHOD_TIME, "is_default": False, "used_for_dates": False,
"start_planet": start_planet}
try:
import swisseph as swe
try:
from ayanamsa_utils import sidereal_flags
except ImportError: # pragma: no cover - package import
from scripts.ayanamsa_utils import sidereal_flags
jd0 = float(birth_jd_ut)
natal = float(natal_moon_longitude) % 360.0
except (ImportError, TypeError, ValueError):
return {**base, "status": "blocked", "reason": "birth_jd_ut_and_swisseph_required"}
flags = sidereal_flags(swe, ayanamsa_name)
def moon(jd: float) -> float:
return float(swe.calc_ut(jd, swe.MOON, flags)[0][0]) % 360.0
offset = ((natal - moon(jd0) + 180.0) % 360.0) - 180.0
if abs(offset) > 0.05: # the natal longitude is not in this frame
return {**base, "status": "blocked", "reason": "natal_moon_frame_mismatch", "offset_degrees": round(offset, 4)}
index = int(natal // NAKSHATRA_SPAN)
def crossing(boundary: float, lo: float, hi: float) -> float:
def ahead(jd: float) -> bool:
return (((moon(jd) + offset - boundary + 180.0) % 360.0) - 180.0) >= 0.0
for _ in range(60):
mid = (lo + hi) / 2.0
if ahead(mid):
hi = mid
else:
lo = mid
return (lo + hi) / 2.0
entry = crossing(index * NAKSHATRA_SPAN, jd0 - 2.0, jd0)
exit_ = crossing((index + 1) * NAKSHATRA_SPAN, jd0, jd0 + 2.0)
elapsed = (jd0 - entry) / (exit_ - entry)
return {
**base,
"status": "computed",
"elapsed_fraction": elapsed,
"remaining_fraction": 1.0 - elapsed,
"nakshatra_entry_jd_ut": round(entry, 6),
"nakshatra_exit_jd_ut": round(exit_, 6),
"basis": "出生时月亮在本月宿已停留的实际时间 ÷ 月亮走完本月宿的实际时间(独立对照,不进日期)",
"difference_from_angle_fraction": None,
}
COVERAGE_BASIS = "actual_solar_return_to_next_actual_solar_return"
COVERAGE_FALLBACK = "nominal_year_fallback_next_return_unknown"
TIME_BASIS = "naive local wall time in the UTC offset of the solar-return instant"
def coverage_fields(raw: Dict[str, Any], year_length: str, coverage_end: Optional[datetime]) -> Dict[str, Any]:
"""(b) the nominal dasha year and (c) the report coverage interval, plus the view label."""
start = _parse(raw["annual_start"])
nominal_end = _parse(raw["annual_end"])
end = coverage_end if isinstance(coverage_end, datetime) and coverage_end > start else None
view_end = end or nominal_end
basis = COVERAGE_BASIS if end else COVERAGE_FALLBACK
return {
"nominal_year": {
"basis": year_length,
"days": float(raw["annual_duration_days"]),
"nominal_year_days": float(raw["annual_duration_days"]),
"start": raw["annual_start"],
"end": raw["annual_end"],
"note": "The dasha's own year length; not stretched to the next solar return.",
},
"coverage_interval": {
"start": raw["annual_start"],
"end": view_end.isoformat(sep=" "),
"basis": basis,
"days": (view_end - start).total_seconds() / 86400.0,
"nominal_end_minus_coverage_end_seconds": round((nominal_end - view_end).total_seconds(), 3),
"time_basis": TIME_BASIS,
},
"periods_view": {
"clipped_to": "coverage_interval",
"coverage_basis": basis,
"raw_field": "raw_periods",
"label": "报告覆盖区间视图(本次真实返照 → 下一次真实返照);完整原始时间线见 raw_periods" if end
else "名义年视图(下一次真实返照未知,暂按 365.25 天年长截);完整原始时间线见 raw_periods",
"label_en": "Report coverage view (this actual solar return -> next actual solar return); full raw timeline in raw_periods" if end
else "Nominal-year view (next solar return unknown, clipped at 365.25 days); full raw timeline in raw_periods",
},
"_view_bounds": (start, view_end),
}
def annual_mudda_dasha(
*,
annual_start: Optional[datetime],
completed_years: Optional[int],
natal_moon_longitude: Optional[float],
year_length: str = DEFAULT_YEAR_LENGTH,
coverage_end: Optional[datetime] = None,
birth_jd_ut: Optional[float] = None,
ayanamsa_name: Any = None,
) -> Dict[str, Any]:
"""Mudda Dasha for one Varsha year, anchored at the solar-return instant.
``coverage_end`` is the next actual solar return in the same local wall
time as ``annual_start``; the clipped ``periods`` view ends there.
"""
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}
year_days = float(raw["annual_duration_days"])
coverage = coverage_fields(raw, year_length, coverage_end)
periods, antardasha_periods = _crop(raw, *coverage.pop("_view_bounds"), year_days=year_days)
balance = balance_block(raw.get("starting_lord"), natal_moon_longitude)
comparison = time_balance_comparison(raw.get("starting_lord"), birth_jd_ut, natal_moon_longitude, ayanamsa_name) \
if birth_jd_ut is not None else {"balance_method": BALANCE_METHOD_TIME, "is_default": False,
"used_for_dates": False, "status": "blocked", "reason": "birth_jd_ut_not_passed"}
if comparison.get("status") == "computed" and balance.get("status") == "computed":
comparison["difference_from_angle_fraction"] = comparison["elapsed_fraction"] - balance["elapsed_fraction"]
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"),
# 占星师 2026-11-08 第五轮裁定 2: angle balance is the default and drives the dates; the
# actual-elapsed-time balance is a separate comparison, never mixed into one number.
"balance": balance,
"balance_method": balance.get("balance_method"),
"balance_time_comparison": comparison,
"nominal_year_days": year_days,
"nominal_year_days_note": NOMINAL_YEAR_DAYS_NOTE,
**coverage,
"periods": periods,
"antardasha_periods": antardasha_periods,
"raw_periods": raw.get("periods"),
"raw_antardasha_periods": raw.get("antardasha_periods"),
"periods_full": raw.get("periods"),
"independent_of_year_lord": True,
"source": SOURCE,
}
# ── 产品 2026-11-07 与占星师上游年运同步:三种余额口径并列 ─────────────────────
DEFAULT_PROFILE = BALANCE_PROFILE
PROFILE_ORDER = ("moon_at_birth_time", "moon_in_varshaphala", "without_dasha_balance")
PROFILE_LABELS = {
# Labels follow upstream annual_report_table_groups.py / jyotish_engine.py.
"moon_at_birth_time": ("出生月亮口径", "Birth Moon profile (Moon at birth time)"),
"moon_in_varshaphala": ("年盘月亮口径", "Annual Moon profile (Moon in Varshaphala)"),
"without_dasha_balance": ("不扣余运口径", "No birth-balance profile (without Dasha balance)"),
}
PROFILE_RULES = {
"moon_at_birth_time": "起运星按占星师第五轮问 7;首段余额按出生时月亮在月宿内的剩余比例",
"moon_in_varshaphala": "起运星按占星师第五轮问 7;首段余额按年盘月亮在月宿内的剩余比例",
"without_dasha_balance": "不计余额,从年主起排九段(上游 PL9 p135 / pl9 导出口径;与第五轮问 7「与年主无关」不一致)",
}
DEFAULT_BASIS = "astrologer_round5_q7_bug_1216"
DEFAULT_BASIS_TEXT = "默认用出生月亮口径:占星师第五轮问 7(BUG-1216)裁定起运星由出生月宿推算、与年主无关"
def _crop(raw: Dict[str, Any], start: datetime, end: datetime, *, year_days: Optional[float] = None) -> tuple[list, list]:
"""The raw rows clipped to [start, end); ``months`` is the share of the nominal year."""
year_days = float(year_days) if year_days else (end - start).total_seconds() / 86400.0
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=" "),
})
antardasha = []
for row in raw.get("antardasha_periods") or []:
if not isinstance(row, dict) or not row.get("start") or not row.get("end"):
continue
p_start = max(_parse(row["start"]), start)
p_end = min(_parse(row["end"]), end)
if p_start >= p_end:
continue
antardasha.append({
"main_order": row.get("main_order"),
"sub_order": row.get("sub_order"),
"main_lord": row.get("main_lord"),
"sub_lord": row.get("sub_lord"),
"duration_days": (p_end - p_start).total_seconds() / 86400.0,
"start": p_start.isoformat(sep=" "),
"end": p_end.isoformat(sep=" "),
})
return periods, antardasha
def annual_mudda_profiles(
*,
annual_start: Optional[datetime],
completed_years: Optional[int],
natal_moon_longitude: Optional[float],
annual_moon_longitude: Optional[float],
varsha_lord: Optional[str],
year_length: str = DEFAULT_YEAR_LENGTH,
coverage_end: Optional[datetime] = None,
) -> Dict[str, Any]:
"""All three upstream Mudda balance conventions, labelled; default first.
Each profile keeps its raw rows and clips ``periods`` to the same coverage
interval as ``annual_mudda_dasha`` (BUG-1722).
"""
profiles: Dict[str, Dict[str, Any]] = {}
days = YEAR_LENGTHS.get(year_length)
for profile in PROFILE_ORDER:
label, label_en = PROFILE_LABELS[profile]
base = {"balance_profile": profile, "label": label, "label_en": label_en,
"rule_text": PROFILE_RULES[profile], "is_default": profile == DEFAULT_PROFILE}
if days is None or not isinstance(annual_start, datetime):
profiles[profile] = {**base, "status": "blocked", "reason": "annual_start_or_year_length_missing"}
continue
if profile == "without_dasha_balance":
raw = calc_mudda_without_dasha_balance(varsha_lord, annual_start, annual_duration_days=days)
else:
balance_moon = natal_moon_longitude if profile == "moon_at_birth_time" else annual_moon_longitude
if completed_years is None or natal_moon_longitude is None or balance_moon is None:
profiles[profile] = {**base, "status": "blocked", "reason": "completed_years_and_moon_longitudes_required"}
continue
raw = calc_mudda_with_dasha_balance(
annual_start=annual_start,
completed_years=int(completed_years),
natal_moon_longitude=natal_moon_longitude,
balance_moon_longitude=balance_moon,
balance_profile=profile,
annual_duration_days=days,
)
if raw.get("status") == "blocked":
profiles[profile] = {**base, "status": "blocked", "reason": raw.get("reason")}
continue
coverage = coverage_fields(raw, year_length, coverage_end)
periods, antardasha = _crop(raw, *coverage.pop("_view_bounds"), year_days=float(raw["annual_duration_days"]))
first = periods[0]["lord"] if periods else None
profiles[profile] = {
**base,
"status": "parameter_sensitive",
"starting_lord": raw.get("starting_lord") or first,
"starting_lord_basis": raw.get("starting_lord_basis"),
"varsha_lord": raw.get("varsha_lord"),
"balance_moon_longitude": raw.get("balance_moon_longitude"),
"balance_fraction_remaining": raw.get("balance_fraction_remaining"),
"balance": balance_block(raw.get("starting_lord") or first, raw.get("balance_moon_longitude"))
if profile != "without_dasha_balance" else {"status": "not_applicable", "balance_method": "none"},
"nominal_year_days": float(raw["annual_duration_days"]),
"annual_start": raw.get("annual_start"),
"annual_end": raw.get("annual_end"),
"annual_duration_days": float(raw["annual_duration_days"]),
"year_length_basis": year_length,
**coverage,
"periods": periods,
"antardasha_periods": antardasha,
"raw_periods": raw.get("periods"),
"raw_antardasha_periods": raw.get("antardasha_periods"),
}
return {
# The wrapper's status is the default schedule's (round-5 Q7 ruling, same
# as `annual_mudda_dasha`); the two alternatives keep parameter_sensitive.
"status": "partial_verified" if profiles[DEFAULT_PROFILE].get("status") != "blocked" else "blocked",
"default_profile": DEFAULT_PROFILE,
"default_basis": DEFAULT_BASIS,
"default_basis_text": DEFAULT_BASIS_TEXT,
"upstream_export_profile": "without_dasha_balance",
"profiles": profiles,
"source": "tajika.calc_mudda_with_dasha_balance / calc_mudda_without_dasha_balance (upstream yinduzhanxing 5fb8379c)",
}