feat(tajika): year lord by the Panchadhikari rule, ported from upstream c7117f92 (BUG-1214)
Round-4 astrologer ruling Q3. tajika.calc_panchadhikari_year_lord and its
missing pieces (Tri-Rasi tables, planet index maps, ayanamsa-aware PyJHora
lookups) plus references/tajika_panchavargiya_rules.json (blob e8c15ac3) are
ported verbatim from yinduzhanxing c7117f92, with one keyword added
(muntha_sign_idx): upstream builds the Muntha-lord candidate from the annual
Lagna + years; ours stays natal Lagna + completed years (BUG-1212).
Both annual producers (solar_return_full_report, cmd_tajika) call the new
tajika_year_lord.select_annual_year_lord, so the pack conflict gate compares
the same rule. Output carries candidates / candidate_list with Panchavargiya
score and engine, aspect flags, selection_basis and a one-sentence basis
(zh/en). The product path always uses the native proxy (PyJHora is AGPL, absent
from the API image and CI); JYOTISH_YEAR_LORD_PYJHORA_REFERENCE=1 restores the
upstream PyJHora lookup for research. Card varshesha_basis and the report
'Selection basis' rows show the sentence; the BUG-1212 stand-in label is gone.
Mudda Dasha and Tri-Pataka follow the selected year lord.
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:
co-authored by
Claude Opus 5.5
parent
bc03bba26b
commit
0afcc2d9a3
@@ -71,11 +71,10 @@ YOGA_PACKET_DOMAINS = {
|
||||
"raman_bvr_relationship_mother_batch_closure_packet_2026_08_20.json": "parents",
|
||||
"raman_bvr_relationship_sibling_core_batch_closure_packet_2026_08_20.json": "siblings",
|
||||
}
|
||||
# BUG-1212 (ruling 甲11): the year lord shown is the Muntha sign lord; the
|
||||
# astrologer's rule is Panchavargiya (strongest of five office-bearers), whose
|
||||
# native producer is still blocked. Shown wherever the year lord is shown.
|
||||
YEAR_LORD_BASIS = "暂用 Muntha 主星;占星师口径为 Panchavargiya,待接入"
|
||||
YEAR_LORD_BASIS_EN = "Muntha sign lord for now; the astrologer's rule is Panchavargiya, not yet wired"
|
||||
# BUG-1214 (round-4 ruling Q3): the year lord is chosen by the Panchadhikari
|
||||
# rule (tajika_year_lord); the card carries its one-sentence basis. Replaces
|
||||
# the BUG-1212 stand-in label. Fallback text only if a producer gives none.
|
||||
YEAR_LORD_BASIS_MISSING = "选法依据缺失"
|
||||
|
||||
TAJIKA_BOUNDARY = (
|
||||
"parameter_sensitive: Tajika / Varshaphala is verified on the first target set only, "
|
||||
@@ -926,7 +925,8 @@ def build_annual_tajika(birth_payload: dict, chart: dict, *, reference: datetime
|
||||
}
|
||||
annual_chart = _record(fields["annual_chart"].get("data"))
|
||||
muntha = _record(fields["muntha"].get("data"))
|
||||
year_lord = _record(fields["year_lord"].get("data")).get("year_lord")
|
||||
year_lord_data = _record(fields["year_lord"].get("data"))
|
||||
year_lord = year_lord_data.get("year_lord")
|
||||
annual_lagna_idx = _sign_index(annual_chart.get("asc_sign"))
|
||||
focus = {"Sun", "Moon", year_lord}
|
||||
aspects = []
|
||||
@@ -950,7 +950,7 @@ def build_annual_tajika(birth_payload: dict, chart: dict, *, reference: datetime
|
||||
"varsha_year": {"start": start.strftime("%Y-%m-%d"), "end": end.strftime("%Y-%m-%d")},
|
||||
"annual_lagna": annual_chart.get("asc_sign"),
|
||||
"varshesha": year_lord,
|
||||
"varshesha_basis": YEAR_LORD_BASIS,
|
||||
"varshesha_basis": year_lord_data.get("selection_basis_text") or YEAR_LORD_BASIS_MISSING,
|
||||
"muntha": {
|
||||
"sign": muntha.get("muntha_sign"),
|
||||
"lord": muntha.get("muntha_lord"),
|
||||
|
||||
@@ -14618,20 +14618,42 @@ def cmd_tajika(args):
|
||||
if age is None:
|
||||
return {"error": "请提供 --age 参数(当前年龄)"}
|
||||
|
||||
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:
|
||||
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),
|
||||
)
|
||||
|
||||
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
|
||||
if mode in ('all', 'yearlord'):
|
||||
result['year_lord'] = calc_year_lord(asc_si, age)
|
||||
result['year_lord'] = year_lord
|
||||
if mode in ('all', 'mudda'):
|
||||
# 需要先获取 varsha_lord
|
||||
yl = calc_year_lord(asc_si, age)
|
||||
varsha_lord = yl.get('year_lord', 'Jupiter')
|
||||
varsha_lord = year_lord.get('year_lord', 'Jupiter')
|
||||
result['mudda_dasha'] = calc_mudda_dasha(asc_si, varsha_lord, args.month)
|
||||
if mode in ('all', 'tripataka'):
|
||||
yl = calc_year_lord(asc_si, age)
|
||||
varsha_lord = yl.get('year_lord', 'Jupiter')
|
||||
varsha_lord = year_lord.get('year_lord', 'Jupiter')
|
||||
muntha_si = (asc_si + age) % 12
|
||||
result['tri_pataka'] = calc_tri_pataka(planet_lons, varsha_lord, muntha_si)
|
||||
return result
|
||||
|
||||
@@ -147,7 +147,10 @@ _PHRASES: dict[str, str] = {
|
||||
"| 要素总数 |": "| Total elements |",
|
||||
# ── annual labels and values ──
|
||||
"Muntha 所在星座的守护星(当前本地口径)": "Ruler of the Muntha sign (current local method)",
|
||||
"暂用 Muntha 主星;占星师口径为 Panchavargiya,待接入": "Muntha sign lord for now; the astrologer's rule is Panchavargiya, not yet wired",
|
||||
# BUG-1214: tajika_year_lord.selection_basis_text (zh) → its en twin.
|
||||
"年盘不全,暂用 Muntha 主星": "Annual chart incomplete; Muntha sign lord used for now",
|
||||
"(Panchavargiya 为本地近似计算)": " (Panchavargiya is a local approximation)",
|
||||
"(Panchavargiya 取 PyJHora 外部参照)": " (Panchavargiya from the PyJHora reference engine)",
|
||||
"两种计算口径给出的年主不同,暂不合并": "The two methods give different year lords; not merged",
|
||||
"(两者不同)": " (they differ)",
|
||||
"太阳返照": "Solar return",
|
||||
@@ -219,6 +222,19 @@ PHRASES_EN: list[tuple[str, str]] = sorted(_PHRASES.items(), key=lambda item: le
|
||||
|
||||
# Templated engine texts. Applied before the phrase table.
|
||||
REGEX_EN: list[tuple[re.Pattern[str], str]] = [
|
||||
# scripts/tajika_year_lord.py selection_basis_text (BUG-1214)
|
||||
(re.compile(r"五类候选中只有([A-Za-z]+)以吉相位照年盘上升,取为年主"),
|
||||
r"Of the five candidates only \1 casts a benefic aspect on the annual Lagna, so it is the year lord"),
|
||||
(re.compile(r"没有候选以吉相位照年盘上升;只有([A-Za-z]+)以凶相位照上升,取为年主"),
|
||||
r"No candidate casts a benefic aspect on the annual Lagna; only \1 casts a malefic one, so it is the year lord"),
|
||||
(re.compile(r"没有候选以吉相位照年盘上升;([A-Za-z、]+)以凶相位照上升,取其中 Panchavargiya 最强的([A-Za-z]+)"),
|
||||
lambda m: (f"No candidate casts a benefic aspect on the annual Lagna; {m.group(1).replace('、', ', ')} cast a malefic one; "
|
||||
f"the strongest by Panchavargiya, {m.group(2)}, is the year lord")),
|
||||
(re.compile(r"([A-Za-z、]+)以吉相位照年盘上升,取其中 Panchavargiya 最强的([A-Za-z]+)"),
|
||||
lambda m: (f"{m.group(1).replace('、', ', ')} cast a benefic aspect on the annual Lagna; "
|
||||
f"the strongest by Panchavargiya, {m.group(2)}, is the year lord")),
|
||||
(re.compile(r"五类候选都不照年盘上升,取全体 Panchavargiya 最强的([A-Za-z]+)"),
|
||||
r"None of the five candidates aspects the annual Lagna; the strongest of all by Panchavargiya, \1, is the year lord"),
|
||||
# scripts/pl9_reader_export.py: f"第{house}宫主:{lord}"
|
||||
(re.compile(r"第(\d+)宫主:"), r"Lord of house \1: "),
|
||||
# scripts/aspects.py / jyotish_engine.py: f"{n}宫相位"
|
||||
|
||||
@@ -576,12 +576,12 @@ def render_pl9_parity_markdown(packet: dict) -> str:
|
||||
if year_lord.get('status') == 'conflict':
|
||||
return '两种计算口径给出的年主不同,暂不合并'
|
||||
data = _dict(year_lord.get('data') or year_lord)
|
||||
muntha_data = _dict(_dict(muntha).get('data'))
|
||||
# BUG-1214: the producer's one-sentence Panchadhikari basis (replaces
|
||||
# the BUG-1212 "Muntha lord for now" stand-in).
|
||||
if data.get('selection_basis_text'):
|
||||
return _cell(data['selection_basis_text'])
|
||||
if data.get('selection_basis'):
|
||||
return _cell(data['selection_basis'])
|
||||
if data.get('year_lord') and data['year_lord'] == muntha_data.get('muntha_lord'):
|
||||
# BUG-1212 (甲11): say plainly that this is a stand-in for Panchavargiya.
|
||||
return '暂用 Muntha 主星;占星师口径为 Panchavargiya,待接入'
|
||||
return '-'
|
||||
|
||||
def _degree(value) -> str:
|
||||
|
||||
+13
-8
@@ -545,7 +545,9 @@ def solar_return_full_report(
|
||||
except Exception as e:
|
||||
result['muntha'] = {'error': str(e)}
|
||||
|
||||
# 3. Year Lord(= Muntha 守护星)
|
||||
# 3. Year Lord:Panchadhikari 选法(占星师第四轮问 3,BUG-1214)。
|
||||
# 与 cmd_tajika 共用 tajika_year_lord.select_annual_year_lord;年盘不全
|
||||
# (降级模式或 Muntha 缺失)时才用 Muntha 主星,并写明 fallback。
|
||||
try:
|
||||
muntha_data = result.get('muntha', {})
|
||||
muntha_sign_val = muntha_data.get('muntha_sign_idx') if isinstance(muntha_data, dict) else None
|
||||
@@ -555,13 +557,16 @@ def solar_return_full_report(
|
||||
'reason': 'muntha_sign_required_for_year_lord',
|
||||
}
|
||||
else:
|
||||
year_lord_sign = muntha_sign_val % 12
|
||||
year_lord = SIGN_LORDS[SIGNS[year_lord_sign]]
|
||||
result['year_lord'] = {
|
||||
'year_lord': year_lord,
|
||||
'year_lord_sign_idx': year_lord_sign,
|
||||
'year_lord_sign': SIGNS[year_lord_sign],
|
||||
}
|
||||
from tajika_year_lord import select_annual_year_lord
|
||||
result['year_lord'] = select_annual_year_lord(
|
||||
sr,
|
||||
natal_asc_sign_idx=sr['birth_asc_sign_idx'],
|
||||
completed_years=age,
|
||||
ayanamsa_name=ayanamsa_name,
|
||||
birth_lat=birth_lat,
|
||||
birth_lon=birth_lon,
|
||||
birth_tz=birth_tz,
|
||||
)
|
||||
except Exception as e:
|
||||
result['year_lord'] = {'error': str(e)}
|
||||
|
||||
|
||||
+163
-6
@@ -27,14 +27,22 @@ SIGN_LORDS = {'Aries':'Mars','Taurus':'Venus','Gemini':'Mercury','Cancer':'Moon'
|
||||
'Leo':'Sun','Virgo':'Mercury','Libra':'Venus','Scorpio':'Mars',
|
||||
'Sagittarius':'Jupiter','Capricorn':'Saturn','Aquarius':'Saturn','Pisces':'Jupiter'}
|
||||
CLASSICAL_PLANETS = ['Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn']
|
||||
PLANET_TO_INDEX = {name: idx for idx, name in enumerate(CLASSICAL_PLANETS)}
|
||||
INDEX_TO_PLANET = {idx: name for name, idx in PLANET_TO_INDEX.items()}
|
||||
BENEFICS = {'Moon', 'Mercury', 'Jupiter', 'Venus'}
|
||||
MALEFICS = {'Sun', 'Mars', 'Saturn'}
|
||||
EXALT_SIGN = {'Sun': 0, 'Moon': 1, 'Mars': 9, 'Mercury': 5, 'Jupiter': 3, 'Venus': 11, 'Saturn': 6}
|
||||
DEBIL_SIGN = {'Sun': 6, 'Moon': 7, 'Mars': 3, 'Mercury': 11, 'Jupiter': 9, 'Venus': 5, 'Saturn': 0}
|
||||
OWN_SIGNS = {'Sun': [4], 'Moon': [3], 'Mars': [0, 7], 'Mercury': [2, 5],
|
||||
'Jupiter': [8, 11], 'Venus': [1, 6], 'Saturn': [9, 10]}
|
||||
# Ported verbatim from upstream yinduzhanxing c7117f92 (BUG-1214): Tri-Rasi
|
||||
# lords for the Panchadhikari year-lord candidates.
|
||||
TRI_RASI_DAYTIME_LORDS = ['Sun', 'Venus', 'Saturn', 'Venus', 'Jupiter', 'Moon', 'Mercury', 'Mars', 'Saturn', 'Mars', 'Jupiter', 'Moon']
|
||||
TRI_RASI_NIGHTTIME_LORDS = ['Jupiter', 'Moon', 'Mercury', 'Mars', 'Sun', 'Venus', 'Saturn', 'Venus', 'Saturn', 'Mars', 'Jupiter', 'Moon']
|
||||
|
||||
SAHAM_RULES_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'references', 'saham_rules.json')
|
||||
PANCHAVARGIYA_RULES_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'references', 'tajika_panchavargiya_rules.json')
|
||||
_PYJHORA_PVB_SIDECAR_CACHE: Dict[tuple, Optional[Dict[str, float]]] = {}
|
||||
|
||||
|
||||
def _load_saham_rules() -> Dict:
|
||||
@@ -45,6 +53,14 @@ def _load_saham_rules() -> Dict:
|
||||
_SAHAM_RULES = _load_saham_rules()
|
||||
|
||||
|
||||
def _load_panchavargiya_rules() -> Dict:
|
||||
with open(PANCHAVARGIYA_RULES_PATH, 'r', encoding='utf-8') as handle:
|
||||
return json.load(handle)
|
||||
|
||||
|
||||
_PANCHAVARGIYA_RULES = _load_panchavargiya_rules()
|
||||
|
||||
|
||||
def _resolve_saham_operand(operand: str, planet_lons: Dict[str, float], asc_lon: float, computed: Dict[str, float]) -> float:
|
||||
if operand == 'Ascendant':
|
||||
return float(asc_lon) % 360
|
||||
@@ -133,6 +149,136 @@ def calc_year_lord(birth_asc_idx: int, age: int) -> Dict:
|
||||
}
|
||||
|
||||
|
||||
def calc_panchadhikari_year_lord(
|
||||
planet_lons: Dict[str, float],
|
||||
annual_asc_sign_idx: int,
|
||||
natal_lagna_sign_idx: int,
|
||||
years_from_dob: int,
|
||||
*,
|
||||
annual_jd: Optional[float] = None,
|
||||
annual_dt: Optional[datetime] = None,
|
||||
lat: Optional[float] = None,
|
||||
lon: Optional[float] = None,
|
||||
tz: Optional[float] = None,
|
||||
ayanamsa_name: Optional[str] = None,
|
||||
muntha_sign_idx: Optional[int] = None,
|
||||
) -> Dict:
|
||||
"""Select Year Lord from Panchadhikari candidates on the annual chart.
|
||||
|
||||
Ported from upstream yinduzhanxing c7117f92 (BUG-1214). One local
|
||||
addition: ``muntha_sign_idx``. Upstream derives the Muntha-lord candidate
|
||||
as (annual ascendant + years); our Muntha is (natal ascendant + completed
|
||||
years) everywhere else (BUG-1212, ruling Q3 keeps it), so the production
|
||||
callers pass it. Omitted, the upstream behaviour is unchanged.
|
||||
|
||||
Rule path reused from the external JHora/PyJHora family:
|
||||
- New-year lord (Sun-sign lord by day / Moon-sign lord by night)
|
||||
- Natal lagna lord
|
||||
- Muntha lord (from annual asc + years)
|
||||
- Varsha lagna lord
|
||||
- Tri-rasi lord
|
||||
Then:
|
||||
- shortlist candidates with benefic aspect to annual asc
|
||||
- if multiple survive, choose highest Panchavargiya Bala among the shortlist
|
||||
- fallback to malefic shortlist or all candidates if needed
|
||||
"""
|
||||
annual_asc_sign_idx = int(annual_asc_sign_idx) % 12
|
||||
natal_lagna_sign_idx = int(natal_lagna_sign_idx) % 12
|
||||
years_from_dob = int(years_from_dob)
|
||||
if 'Sun' not in planet_lons or 'Moon' not in planet_lons:
|
||||
return {'status': 'blocked', 'reason': 'sun_or_moon_missing_for_panchadhikari'}
|
||||
|
||||
annual_sun_sign_idx = _sign_idx_from_lon(planet_lons['Sun'])
|
||||
annual_moon_sign_idx = _sign_idx_from_lon(planet_lons['Moon'])
|
||||
asc_lon = float(annual_asc_sign_idx * 30)
|
||||
is_daytime = _year_lord_is_daytime(
|
||||
annual_dt=annual_dt,
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
tz=tz,
|
||||
sun_lon=float(planet_lons['Sun']),
|
||||
asc_lon=asc_lon,
|
||||
)
|
||||
|
||||
if muntha_sign_idx is None:
|
||||
muntha_sign_idx = (annual_asc_sign_idx + years_from_dob) % 12
|
||||
muntha_sign_idx = int(muntha_sign_idx) % 12
|
||||
raw_candidates = [
|
||||
('new_year_lord', SIGN_LORDS[SIGNS[annual_sun_sign_idx if is_daytime else annual_moon_sign_idx]]),
|
||||
('natal_lagna_lord', SIGN_LORDS[SIGNS[natal_lagna_sign_idx]]),
|
||||
('muntha_lord', SIGN_LORDS[SIGNS[muntha_sign_idx]]),
|
||||
('varsha_lagna_lord', SIGN_LORDS[SIGNS[annual_asc_sign_idx]]),
|
||||
('tri_rasi_lord', TRI_RASI_DAYTIME_LORDS[annual_asc_sign_idx] if is_daytime else TRI_RASI_NIGHTTIME_LORDS[annual_asc_sign_idx]),
|
||||
]
|
||||
|
||||
candidates = []
|
||||
seen = set()
|
||||
for source, planet in raw_candidates:
|
||||
if planet in seen or planet not in planet_lons:
|
||||
continue
|
||||
seen.add(planet)
|
||||
planet_house = _sign_idx_from_lon(planet_lons[planet])
|
||||
pvb = _resolve_year_lord_panchavargiya_score(
|
||||
planet,
|
||||
planet_lons[planet],
|
||||
annual_jd=annual_jd,
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
tz=tz,
|
||||
ayanamsa_name=ayanamsa_name,
|
||||
)
|
||||
candidates.append(
|
||||
{
|
||||
'source': source,
|
||||
'planet': planet,
|
||||
'planet_house': planet_house,
|
||||
'planet_sign': SIGNS[planet_house],
|
||||
'benefic_aspects_asc': annual_asc_sign_idx in _benefic_aspect_targets(planet_house),
|
||||
'malefic_aspects_asc': annual_asc_sign_idx in _malefic_aspect_targets(planet_house),
|
||||
'panchavargiya_score': pvb['score'],
|
||||
'panchavargiya_engine': pvb['engine'],
|
||||
'panchavargiya_components': pvb['components'],
|
||||
}
|
||||
)
|
||||
|
||||
if not candidates:
|
||||
return {'status': 'blocked', 'reason': 'no_panchadhikari_candidates_available'}
|
||||
|
||||
benefic_shortlist = [row for row in candidates if row['benefic_aspects_asc']]
|
||||
malefic_shortlist = [row for row in candidates if row['malefic_aspects_asc']]
|
||||
|
||||
if len(benefic_shortlist) == 1:
|
||||
winner = benefic_shortlist[0]
|
||||
selection_basis = 'panchadhikari_single_benefic'
|
||||
elif len(benefic_shortlist) > 1:
|
||||
winner = max(benefic_shortlist, key=lambda row: (row['panchavargiya_score'], -candidates.index(row)))
|
||||
selection_basis = 'panchadhikari_benefic_shortlist_pvb'
|
||||
elif len(malefic_shortlist) == 1:
|
||||
winner = malefic_shortlist[0]
|
||||
selection_basis = 'panchadhikari_single_malefic'
|
||||
elif len(malefic_shortlist) > 1:
|
||||
winner = max(malefic_shortlist, key=lambda row: (row['panchavargiya_score'], -candidates.index(row)))
|
||||
selection_basis = 'panchadhikari_malefic_shortlist_pvb'
|
||||
else:
|
||||
winner = max(candidates, key=lambda row: (row['panchavargiya_score'], -candidates.index(row)))
|
||||
selection_basis = 'panchadhikari_pvb_fallback_all_candidates'
|
||||
|
||||
return {
|
||||
'status': 'partial_verified',
|
||||
'year_lord': winner['planet'],
|
||||
'year_lord_sign_idx': winner['planet_house'],
|
||||
'year_lord_sign': winner['planet_sign'],
|
||||
'selection_basis': selection_basis,
|
||||
'annual_asc_sign_idx': annual_asc_sign_idx,
|
||||
'annual_asc_sign': SIGNS[annual_asc_sign_idx],
|
||||
'muntha_sign_idx': muntha_sign_idx,
|
||||
'muntha_sign': SIGNS[muntha_sign_idx],
|
||||
'is_daytime': is_daytime,
|
||||
'panchavargiya_engine': winner['panchavargiya_engine'],
|
||||
'candidates': candidates,
|
||||
}
|
||||
|
||||
|
||||
def calc_varshaphala(birth_datetime: datetime,
|
||||
target_year: int,
|
||||
birth_lon: float, birth_lat: float,
|
||||
@@ -955,6 +1101,7 @@ def _resolve_year_lord_panchavargiya_score(
|
||||
lat: Optional[float],
|
||||
lon: Optional[float],
|
||||
tz: Optional[float],
|
||||
ayanamsa_name: Optional[str],
|
||||
) -> Dict:
|
||||
external = _external_pyjhora_panchavargiya_score(
|
||||
planet,
|
||||
@@ -962,6 +1109,7 @@ def _resolve_year_lord_panchavargiya_score(
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
tz=tz,
|
||||
ayanamsa_name=ayanamsa_name,
|
||||
)
|
||||
if external is not None:
|
||||
return external
|
||||
@@ -980,6 +1128,7 @@ def _external_pyjhora_panchavargiya_score(
|
||||
lat: Optional[float],
|
||||
lon: Optional[float],
|
||||
tz: Optional[float],
|
||||
ayanamsa_name: Optional[str],
|
||||
) -> Optional[Dict]:
|
||||
if annual_jd is None or lat is None or lon is None or tz is None:
|
||||
return None
|
||||
@@ -987,7 +1136,9 @@ def _external_pyjhora_panchavargiya_score(
|
||||
from jhora.panchanga import drik as j_drik
|
||||
from jhora.horoscope.chart import strength as j_strength
|
||||
place = j_drik.Place('annual_year_lord_probe', float(lat), float(lon), float(tz))
|
||||
scores = j_strength.pancha_vargeeya_bala(float(annual_jd), place)
|
||||
# PyJHora annual-chart APIs use the local civil JD for a Place whose
|
||||
# timezone is already supplied. ``annual_jd`` reaches us as UT.
|
||||
scores = j_strength.pancha_vargeeya_bala(float(annual_jd) + float(tz) / 24.0, place)
|
||||
planet_idx = PLANET_TO_INDEX.get(planet)
|
||||
if planet_idx is None:
|
||||
return None
|
||||
@@ -998,7 +1149,7 @@ def _external_pyjhora_panchavargiya_score(
|
||||
'components': {'pyjhora_pancha_vargeeya_bala': float(score)},
|
||||
}
|
||||
except Exception:
|
||||
return _external_pyjhora_panchavargiya_score_sidecar(planet, annual_jd, lat, lon, tz)
|
||||
return _external_pyjhora_panchavargiya_score_sidecar(planet, annual_jd, lat, lon, tz, ayanamsa_name)
|
||||
|
||||
|
||||
def _external_pyjhora_panchavargiya_score_sidecar(
|
||||
@@ -1007,13 +1158,14 @@ def _external_pyjhora_panchavargiya_score_sidecar(
|
||||
lat: float,
|
||||
lon: float,
|
||||
tz: float,
|
||||
ayanamsa_name: Optional[str],
|
||||
) -> Optional[Dict]:
|
||||
"""Read optional PyJHora Panchavargiya Bala scores via an external Python.
|
||||
|
||||
PyJHora stays an optional black-box oracle. We do not vendor it, import it
|
||||
as a hard dependency, or fail native execution when it is absent.
|
||||
"""
|
||||
scores = _external_pyjhora_panchavargiya_scores_sidecar(annual_jd, lat, lon, tz)
|
||||
scores = _external_pyjhora_panchavargiya_scores_sidecar(annual_jd, lat, lon, tz, ayanamsa_name)
|
||||
if not scores or planet not in scores:
|
||||
return None
|
||||
score = scores[planet]
|
||||
@@ -1033,8 +1185,12 @@ def _external_pyjhora_panchavargiya_scores_sidecar(
|
||||
lat: float,
|
||||
lon: float,
|
||||
tz: float,
|
||||
ayanamsa_name: Optional[str] = None,
|
||||
) -> Optional[Dict[str, float]]:
|
||||
key = (round(float(annual_jd), 8), round(float(lat), 6), round(float(lon), 6), round(float(tz), 4))
|
||||
if os.environ.get("JYOTISH_SKIP_PYJHORA_SIDECAR") == "1":
|
||||
return None
|
||||
effective_ayanamsa = str(ayanamsa_name or 'lahiri').strip().upper()
|
||||
key = (round(float(annual_jd), 8), round(float(lat), 6), round(float(lon), 6), round(float(tz), 4), effective_ayanamsa)
|
||||
if key in _PYJHORA_PVB_SIDECAR_CACHE:
|
||||
return _PYJHORA_PVB_SIDECAR_CACHE[key]
|
||||
executable = _resolve_external_pyjhora_python()
|
||||
@@ -1050,12 +1206,13 @@ with contextlib.redirect_stdout(io.StringIO()):
|
||||
from jhora.panchanga import drik as j_drik
|
||||
from jhora.horoscope.chart import strength as j_strength
|
||||
place = j_drik.Place('annual_year_lord_probe', float(sys.argv[2]), float(sys.argv[3]), float(sys.argv[4]))
|
||||
scores = j_strength.pancha_vargeeya_bala(float(sys.argv[1]), place)
|
||||
j_drik.set_ayanamsa_mode(sys.argv[5])
|
||||
scores = j_strength.pancha_vargeeya_bala(float(sys.argv[1]) + float(sys.argv[4]) / 24.0, place)
|
||||
print(json.dumps({str(k): float(v) for k, v in dict(scores).items()}))
|
||||
"""
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
[executable, '-c', code, str(float(annual_jd)), str(float(lat)), str(float(lon)), str(float(tz))],
|
||||
[executable, '-c', code, str(float(annual_jd)), str(float(lat)), str(float(lon)), str(float(tz)), effective_ayanamsa],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tajika year lord (Varshesha) by the Panchadhikari rule (BUG-1214).
|
||||
|
||||
Astrologer round-4 ruling 2026-10-03, Q3: five candidates (day/night new-year
|
||||
lord, natal Lagna lord, Muntha lord, annual Lagna lord, Tri-Rasi lord) ->
|
||||
those casting a benefic Tajika aspect on the annual Lagna, strongest by
|
||||
Panchavargiya -> else those casting a malefic aspect, strongest -> else the
|
||||
strongest of all (`panchadhikari_pvb_fallback_all_candidates`).
|
||||
|
||||
The selection itself is `tajika.calc_panchadhikari_year_lord`, ported from
|
||||
upstream yinduzhanxing c7117f92. This module is the one place both annual
|
||||
producers (`solar_return.solar_return_full_report` and
|
||||
`jyotish_engine.cmd_tajika`) call, so `annual_tajika_pack`'s conflict gate
|
||||
compares two runs of the same rule. Muntha stays natal Lagna + completed
|
||||
years (BUG-1212); it is handed to the selector explicitly.
|
||||
|
||||
Panchavargiya engine (local divergence from upstream, BUG-1214): upstream
|
||||
hands the annual JD to the selector, which then imports PyJHora in-process
|
||||
when the interpreter has it. PyJHora is AGPL and stays an external reference
|
||||
engine (requirements-reference-engines.txt); the API image and CI do not have
|
||||
it, so a developer machine that does would pick a different year lord than
|
||||
production. The product path therefore always uses the native proxy
|
||||
(`engine: native_proxy`); set JYOTISH_YEAR_LORD_PYJHORA_REFERENCE=1 to pass the
|
||||
annual JD and let the upstream PyJHora lookup run (research comparison only).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
try: # scripts/ on sys.path
|
||||
from tajika import SIGNS, SIGN_LORDS, calc_panchadhikari_year_lord
|
||||
except ImportError: # pragma: no cover - package import
|
||||
from scripts.tajika import SIGNS, SIGN_LORDS, calc_panchadhikari_year_lord
|
||||
|
||||
PYJHORA_REFERENCE_ENV = "JYOTISH_YEAR_LORD_PYJHORA_REFERENCE"
|
||||
SOURCE = "tajika.calc_panchadhikari_year_lord (upstream yinduzhanxing c7117f92)"
|
||||
MUNTHA_BASIS = "natal_ascendant_plus_completed_years"
|
||||
FALLBACK_BASIS = "fallback_muntha_sign_lord_after_panchadhikari_block"
|
||||
|
||||
ENGINE_NOTE = {
|
||||
"native_proxy": ("Panchavargiya 为本地近似计算", "Panchavargiya is a local approximation"),
|
||||
"pyjhora": ("Panchavargiya 取 PyJHora 外部参照", "Panchavargiya from the PyJHora reference engine"),
|
||||
"pyjhora_sidecar": ("Panchavargiya 取 PyJHora 外部参照", "Panchavargiya from the PyJHora reference engine"),
|
||||
}
|
||||
|
||||
|
||||
def _names(rows: List[Dict[str, Any]], sep: str) -> str:
|
||||
return sep.join(str(row.get("planet")) for row in rows)
|
||||
|
||||
|
||||
def selection_basis_text(data: Dict[str, Any]) -> Tuple[str, str]:
|
||||
"""One sentence (zh, en) saying why this planet is the year lord."""
|
||||
basis = data.get("selection_basis")
|
||||
lord = data.get("year_lord")
|
||||
if basis == FALLBACK_BASIS:
|
||||
return ("年盘不全,暂用 Muntha 主星", "Annual chart incomplete; Muntha sign lord used for now")
|
||||
candidates = [row for row in data.get("candidates") or [] if isinstance(row, dict)]
|
||||
benefic = [row for row in candidates if row.get("benefic_aspects_asc")]
|
||||
malefic = [row for row in candidates if row.get("malefic_aspects_asc")]
|
||||
if basis == "panchadhikari_single_benefic":
|
||||
zh = f"五类候选中只有{lord}以吉相位照年盘上升,取为年主"
|
||||
en = f"Of the five candidates only {lord} casts a benefic aspect on the annual Lagna, so it is the year lord"
|
||||
elif basis == "panchadhikari_benefic_shortlist_pvb":
|
||||
zh = f"{_names(benefic, '、')}以吉相位照年盘上升,取其中 Panchavargiya 最强的{lord}"
|
||||
en = f"{_names(benefic, ', ')} cast a benefic aspect on the annual Lagna; the strongest by Panchavargiya, {lord}, is the year lord"
|
||||
elif basis == "panchadhikari_single_malefic":
|
||||
zh = f"没有候选以吉相位照年盘上升;只有{lord}以凶相位照上升,取为年主"
|
||||
en = f"No candidate casts a benefic aspect on the annual Lagna; only {lord} casts a malefic one, so it is the year lord"
|
||||
elif basis == "panchadhikari_malefic_shortlist_pvb":
|
||||
zh = f"没有候选以吉相位照年盘上升;{_names(malefic, '、')}以凶相位照上升,取其中 Panchavargiya 最强的{lord}"
|
||||
en = (f"No candidate casts a benefic aspect on the annual Lagna; {_names(malefic, ', ')} cast a malefic one; "
|
||||
f"the strongest by Panchavargiya, {lord}, is the year lord")
|
||||
elif basis == "panchadhikari_pvb_fallback_all_candidates":
|
||||
zh = f"五类候选都不照年盘上升,取全体 Panchavargiya 最强的{lord}"
|
||||
en = f"None of the five candidates aspects the annual Lagna; the strongest of all by Panchavargiya, {lord}, is the year lord"
|
||||
else:
|
||||
return ("-", "-")
|
||||
note = ENGINE_NOTE.get(str(data.get("panchavargiya_engine")))
|
||||
if note:
|
||||
zh += f"({note[0]})"
|
||||
en += f" ({note[1]})"
|
||||
return zh, en
|
||||
|
||||
|
||||
def _candidate_list(candidates: List[Dict[str, Any]], winner: Optional[str]) -> List[Dict[str, Any]]:
|
||||
return [{
|
||||
"source": row.get("source"),
|
||||
"planet": row.get("planet"),
|
||||
"planet_sign": row.get("planet_sign"),
|
||||
"benefic_aspects_asc": bool(row.get("benefic_aspects_asc")),
|
||||
"malefic_aspects_asc": bool(row.get("malefic_aspects_asc")),
|
||||
"panchavargiya_score": row.get("panchavargiya_score"),
|
||||
"panchavargiya_engine": row.get("panchavargiya_engine"),
|
||||
"selected": row.get("planet") == winner,
|
||||
} for row in candidates if isinstance(row, dict)]
|
||||
|
||||
|
||||
def _select(sr, natal_asc_sign_idx, completed_years, muntha_sign_idx,
|
||||
ayanamsa_name, birth_lat, birth_lon, birth_tz) -> Dict[str, Any]:
|
||||
info = sr.get("solar_return") or {}
|
||||
location = sr.get("annual_location") or {}
|
||||
return calc_panchadhikari_year_lord(
|
||||
sr.get("planet_lons") or {},
|
||||
annual_asc_sign_idx=sr["asc_sign_idx"],
|
||||
natal_lagna_sign_idx=int(natal_asc_sign_idx),
|
||||
years_from_dob=int(completed_years),
|
||||
annual_jd=info.get("jd_ut") if os.environ.get(PYJHORA_REFERENCE_ENV) == "1" else None,
|
||||
annual_dt=info.get("dt_local") or info.get("dt_ut"),
|
||||
lat=location.get("latitude", birth_lat),
|
||||
lon=location.get("longitude", birth_lon),
|
||||
tz=location.get("utc_offset", birth_tz),
|
||||
ayanamsa_name=ayanamsa_name,
|
||||
muntha_sign_idx=muntha_sign_idx,
|
||||
)
|
||||
|
||||
|
||||
def select_annual_year_lord(
|
||||
sr: Dict[str, Any],
|
||||
*,
|
||||
natal_asc_sign_idx: int,
|
||||
completed_years: int,
|
||||
ayanamsa_name: Optional[str],
|
||||
birth_lat: float,
|
||||
birth_lon: float,
|
||||
birth_tz: float,
|
||||
) -> Dict[str, Any]:
|
||||
"""Year lord for one solar-return chart (`solar_return.calc_solar_return_chart`)."""
|
||||
muntha_sign_idx = (int(natal_asc_sign_idx) + int(completed_years)) % 12
|
||||
# PyJHora (optional reference engine, absent from the API image) prints
|
||||
# on import; keep stdout clean for the CLI JSON, as upstream cmd_tajika does.
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
data = _select(sr, natal_asc_sign_idx, completed_years, muntha_sign_idx,
|
||||
ayanamsa_name, birth_lat, birth_lon, birth_tz)
|
||||
if data.get("status") != "partial_verified":
|
||||
lord = SIGN_LORDS[SIGNS[muntha_sign_idx]]
|
||||
data = {
|
||||
"year_lord": lord,
|
||||
"year_lord_sign_idx": muntha_sign_idx,
|
||||
"year_lord_sign": SIGNS[muntha_sign_idx],
|
||||
"selection_basis": FALLBACK_BASIS,
|
||||
"fallback_reason": data.get("reason", "panchadhikari_blocked"),
|
||||
}
|
||||
data["muntha_basis"] = MUNTHA_BASIS
|
||||
data["candidate_list"] = _candidate_list(data.get("candidates") or [], data.get("year_lord"))
|
||||
data["source"] = SOURCE
|
||||
zh, en = selection_basis_text(data)
|
||||
data["selection_basis_text"] = zh
|
||||
data["selection_basis_text_en"] = en
|
||||
return data
|
||||
Reference in New Issue
Block a user