feat(tajika): year lord compared by the Tajika five-fold Panchavargiya (BUG-1217)

Ports _calc_native_panchavargiya_bala and its helpers verbatim from upstream
yinduzhanxing c7117f92 (astrologer round-5 Q5). calc_panchadhikari_year_lord
now scores candidates with it (engine tajika_native_five_fold); the
D1/D2/D3/D9/D12 dignity proxy no longer selects a year lord, and a missing
annual planet blocks instead of falling back to it. Basis note reads
「按 Tajika 五分力量」 / "by Tajika five-fold strength".

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 21:09:31 +08:00
co-authored by Claude Opus 5.5
parent 9d81f15afa
commit 1c39988b3c
6 changed files with 351 additions and 22 deletions
+2
View File
@@ -153,6 +153,8 @@ _PHRASES: dict[str, str] = {
# 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)",
# BUG-1217: tajika_year_lord.ENGINE_NOTE["tajika_native_five_fold"]
"(按 Tajika 五分力量)": " (by Tajika five-fold strength)",
"(Panchavargiya 取 PyJHora 外部参照)": " (Panchavargiya from the PyJHora reference engine)",
"两种计算口径给出的年主不同,暂不合并": "The two methods give different year lords; not merged",
"(两者不同)": " (they differ)",
+179 -5
View File
@@ -14,6 +14,7 @@ Tajika/Varshaphala年运盘模块 v1.0
"""
from typing import Dict, List, Optional
from datetime import datetime, timedelta
from decimal import Decimal, ROUND_HALF_UP
import math
import json
import os
@@ -211,6 +212,13 @@ def calc_panchadhikari_year_lord(
('tri_rasi_lord', TRI_RASI_DAYTIME_LORDS[annual_asc_sign_idx] if is_daytime else TRI_RASI_NIGHTTIME_LORDS[annual_asc_sign_idx]),
]
# Local divergence (BUG-1217, astrologer round-5 Q5): candidates are
# compared by the Tajika five-fold Panchavargiya (upstream
# _calc_native_panchavargiya_bala), not the D1/D2/D3/D9/D12 dignity proxy.
# Upstream c7117f92 still falls back to the proxy here (upstream bug).
native_scores = _calc_native_panchavargiya_bala(
{planet: planet_lons[planet] for planet in CLASSICAL_PLANETS if planet in planet_lons}
)
candidates = []
seen = set()
for source, planet in raw_candidates:
@@ -226,7 +234,11 @@ def calc_panchadhikari_year_lord(
lon=lon,
tz=tz,
ayanamsa_name=ayanamsa_name,
native_scores=native_scores,
)
if pvb is None:
return {'status': 'blocked', 'reason': 'tajika_five_fold_panchavargiya_unavailable',
'detail': (native_scores.get(planet) or {}).get('reason')}
candidates.append(
{
'source': source,
@@ -236,6 +248,7 @@ def calc_panchadhikari_year_lord(
'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_raw_total': pvb.get('raw_total'),
'panchavargiya_engine': pvb['engine'],
'panchavargiya_components': pvb['components'],
}
@@ -626,6 +639,154 @@ def calc_tajika_strength_layers(
}
# Ported verbatim from upstream yinduzhanxing c7117f92 (BUG-1217): Tajika five-fold
# Panchavargiya Bala (Kshetra/graha, Uchcha, Hadda, Drekkana, Navamsa; raw /80, score /20),
# rules in references/tajika_panchavargiya_rules.json. Astrologer round-5 Q5.
def _tajika_relation(planet: str, lord: str, annual_signs: Dict[str, int]) -> Dict:
"""Return the Tajika aspect relation used by Panchavargeeya Bala.
Tajika uses houses from the lord's annual-chart position to the planet's
annual-chart position: 3/5/9/11 friendly, 2/6/8/12 neutral, and
1/4/7/10 inimical. This intentionally does not use natural friendship or
Parashari Panchadha Maitri.
"""
if planet == lord:
return {'relation': 'own', 'house_from_lord': None}
house = ((annual_signs[planet] - annual_signs[lord]) % 12) + 1
relation_rules = _PANCHAVARGIYA_RULES['tajika_relation']
if house in relation_rules['friendly_houses']:
relation = 'friendly'
elif house in relation_rules['neutral_houses']:
relation = 'neutral'
else:
relation = 'inimical'
return {'relation': relation, 'house_from_lord': house}
def _panchavargiya_component(
planet: str,
lord: str,
annual_signs: Dict[str, int],
points: Dict[str, float],
*,
subdivision: str,
) -> Dict:
trace = _tajika_relation(planet, lord, annual_signs)
return {
'score': float(points[trace['relation']]),
'lord': lord,
'relation': trace['relation'],
'house_from_lord': trace['house_from_lord'],
'subdivision': subdivision,
}
def _hudda_lord(lon: float) -> str:
sign_idx = _sign_idx_from_lon(lon)
degree = float(lon) % 30.0
for boundary, lord in _PANCHAVARGIYA_RULES['hudda'][sign_idx]:
if degree < float(boundary) or math.isclose(degree, float(boundary)) and float(boundary) == 30.0:
return lord
raise ValueError(f'No Tajika Hudda lord for longitude {lon}')
def _tajika_drekkana_lord(lon: float) -> str:
sign_idx = _sign_idx_from_lon(lon)
division = min(2, int((float(lon) % 30.0) / 10.0))
return _PANCHAVARGIYA_RULES['drekkana_lords'][sign_idx][division]
def _navamsha_sign_idx(lon: float) -> int:
sign_idx = _sign_idx_from_lon(lon)
division = min(8, int((float(lon) % 30.0) / (30.0 / 9.0)))
if sign_idx in (0, 3, 6, 9):
start = sign_idx
elif sign_idx in (1, 4, 7, 10):
start = (sign_idx + 8) % 12
else:
start = (sign_idx + 4) % 12
return (start + division) % 12
def _tajika_uchcha_bala(planet: str, lon: float) -> float:
deep_debilitation = {
'Sun': 190.0, 'Moon': 213.0, 'Mars': 118.0, 'Mercury': 345.0,
'Jupiter': 275.0, 'Venus': 177.0, 'Saturn': 20.0,
}[planet]
distance = (float(lon) % 360.0 - deep_debilitation) % 360.0
if distance > 180.0:
distance = 360.0 - distance
return _round_half_up(distance / 9.0)
def _round_half_up(value: float, places: int = 2) -> float:
quantizer = Decimal('1').scaleb(-places)
return float(Decimal(str(value)).quantize(quantizer, rounding=ROUND_HALF_UP))
def _calc_native_panchavargiya_bala(planet_lons: Dict[str, float]) -> Dict[str, Dict]:
annual_signs = {planet: _sign_idx_from_lon(lon) for planet, lon in planet_lons.items()}
required = set(CLASSICAL_PLANETS)
if not required.issubset(annual_signs):
missing = sorted(required - set(annual_signs))
return {
planet: {'status': 'blocked', 'reason': f'annual_planet_positions_missing:{",".join(missing)}'}
for planet in planet_lons
}
result = {}
for planet, longitude in planet_lons.items():
sign_lord = SIGN_LORDS[SIGNS[annual_signs[planet]]]
graha = _panchavargiya_component(
planet, sign_lord, annual_signs,
{'own': 30.0, 'friendly': 22.5, 'neutral': 15.0, 'inimical': 7.5},
subdivision='rasi',
)
hudda = _panchavargiya_component(
planet, _hudda_lord(longitude), annual_signs,
{'own': 15.0, 'friendly': 11.25, 'neutral': 7.5, 'inimical': 3.75},
subdivision='hudda',
)
drekkana = _panchavargiya_component(
planet, _tajika_drekkana_lord(longitude), annual_signs,
{'own': 10.0, 'friendly': 7.5, 'neutral': 5.0, 'inimical': 2.5},
subdivision='tajika_drekkana',
)
navamsha_sign = _navamsha_sign_idx(longitude)
navamsha = _panchavargiya_component(
planet, SIGN_LORDS[SIGNS[navamsha_sign]], annual_signs,
{'own': 5.0, 'friendly': 3.75, 'neutral': 2.5, 'inimical': 1.25},
subdivision='navamsha',
)
components = {
'graha_bala': graha['score'],
'uchcha_bala': _tajika_uchcha_bala(planet, longitude),
'hudda_bala': hudda['score'],
'drekkana_bala': drekkana['score'],
'navamsha_bala': navamsha['score'],
}
raw_total = _round_half_up(sum(components.values()))
result[planet] = {
'status': 'native_rule_backed',
'engine': 'native_tajika_panchavargiya',
'score': _round_half_up(raw_total / 4.0),
'raw_total': raw_total,
'components': components,
'component_trace': {
'graha_bala': graha,
'hudda_bala': hudda,
'drekkana_bala': drekkana,
'navamsha_bala': {**navamsha, 'navamsha_sign': SIGNS[navamsha_sign]},
'uchcha_bala': {'score': components['uchcha_bala'], 'formula': 'shortest_arc_from_deep_debilitation_divided_by_9'},
},
'component_total_consistency': {
'status': 'consistent', 'formula': 'sum(Graha, Uchcha, Hudda, Drekkana, Navamsha) / 4',
'raw_total': raw_total, 'score': _round_half_up(raw_total / 4.0),
},
'rule_source': 'references/tajika_panchavargiya_rules.json',
}
return result
def _is_number(value) -> bool:
try:
float(value)
@@ -1215,7 +1376,8 @@ def _resolve_year_lord_panchavargiya_score(
lon: Optional[float],
tz: Optional[float],
ayanamsa_name: Optional[str],
) -> Dict:
native_scores: Optional[Dict[str, Dict]] = None,
) -> Optional[Dict]:
external = _external_pyjhora_panchavargiya_score(
planet,
annual_jd=annual_jd,
@@ -1226,11 +1388,23 @@ def _resolve_year_lord_panchavargiya_score(
)
if external is not None:
return external
proxy = _calc_panchavargiya_bala_for_planet(planet, lon_value)
if native_scores is None:
# Upstream c7117f92 behaviour (dignity proxy); no product caller.
proxy = _calc_panchavargiya_bala_for_planet(planet, lon_value)
return {
'score': proxy['score'],
'engine': 'native_proxy',
'components': proxy['components'],
}
# BUG-1217: Tajika five-fold Panchavargiya; None when it cannot be computed.
native = native_scores.get(planet) or {}
if native.get('status') != 'native_rule_backed':
return None
return {
'score': proxy['score'],
'engine': 'native_proxy',
'components': proxy['components'],
'score': native['score'],
'engine': 'tajika_native_five_fold',
'raw_total': native['raw_total'],
'components': native['components'],
}
+14 -8
View File
@@ -14,14 +14,18 @@ producers (`solar_return.solar_return_full_report` and
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).
Panchavargiya engine (local divergence from upstream, BUG-1214 / BUG-1217):
upstream hands the annual JD to the selector, which then imports PyJHora
in-process when the interpreter has it, else falls back to a D1/D2/D3/D9/D12
dignity proxy. PyJHora is AGPL and stays an external reference engine
(requirements-reference-engines.txt); the API image and CI do not have it, so
the product path never hands it the JD. Since BUG-1217 (astrologer round-5
Q5) the candidates are compared by the Tajika five-fold Panchavargiya
(upstream `_calc_native_panchavargiya_bala`: Kshetra/graha, Uchcha, Hadda,
Drekkana, Navamsa; raw out of 80, score = raw / 4 out of 20;
`engine: tajika_native_five_fold`); the dignity proxy no longer selects a year
lord. 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
@@ -42,6 +46,7 @@ MUNTHA_BASIS = "natal_ascendant_plus_completed_years"
FALLBACK_BASIS = "fallback_muntha_sign_lord_after_panchadhikari_block"
ENGINE_NOTE = {
"tajika_native_five_fold": ("按 Tajika 五分力量", "by Tajika five-fold strength"),
"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"),
@@ -94,6 +99,7 @@ def _candidate_list(candidates: List[Dict[str, Any]], winner: Optional[str]) ->
"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_raw_total": row.get("panchavargiya_raw_total"),
"panchavargiya_engine": row.get("panchavargiya_engine"),
"selected": row.get("planet") == winner,
} for row in candidates if isinstance(row, dict)]