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:
Jesse_Chen
2026-10-03 16:30:28 +08:00
co-authored by Claude Opus 5.5
parent bc03bba26b
commit 0afcc2d9a3
11 changed files with 633 additions and 45 deletions
+7 -7
View File
@@ -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"),
+28 -6
View File
@@ -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
+17 -1
View File
@@ -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}宫相位"
+4 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+153
View File
@@ -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