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
+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,