- 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
399 lines
20 KiB
Python
399 lines
20 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);
|
||
- 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)",
|
||
}
|