diff --git a/references/tajika_panchavargiya_rules.json b/references/tajika_panchavargiya_rules.json new file mode 100644 index 00000000..e8c15ac3 --- /dev/null +++ b/references/tajika_panchavargiya_rules.json @@ -0,0 +1,30 @@ +{ + "source": "Tajika Panchavargeeya Bala tables cross-checked against K. S. Charak, A Textbook of Varshaphala; Shanker Adawal public Varshaphala reference pages (retrieved 2026-09-07).", + "tajika_relation": { + "friendly_houses": [3, 5, 9, 11], + "neutral_houses": [2, 6, 8, 12], + "inimical_houses": [1, 4, 7, 10] + }, + "hudda": [ + [[6, "Jupiter"], [12, "Venus"], [20, "Mercury"], [25, "Mars"], [30, "Saturn"]], + [[8, "Venus"], [14, "Mercury"], [22, "Jupiter"], [27, "Saturn"], [30, "Mars"]], + [[6, "Mercury"], [12, "Venus"], [17, "Jupiter"], [24, "Mars"], [30, "Saturn"]], + [[7, "Mars"], [13, "Venus"], [19, "Mercury"], [26, "Jupiter"], [30, "Saturn"]], + [[6, "Jupiter"], [11, "Venus"], [18, "Saturn"], [24, "Mercury"], [30, "Mars"]], + [[7, "Mercury"], [17, "Venus"], [21, "Jupiter"], [28, "Mars"], [30, "Saturn"]], + [[6, "Saturn"], [14, "Mercury"], [21, "Jupiter"], [28, "Venus"], [30, "Mars"]], + [[7, "Mars"], [11, "Venus"], [19, "Mercury"], [24, "Jupiter"], [30, "Saturn"]], + [[12, "Jupiter"], [17, "Venus"], [21, "Mercury"], [26, "Mars"], [30, "Saturn"]], + [[7, "Mercury"], [14, "Jupiter"], [22, "Venus"], [26, "Saturn"], [30, "Mars"]], + [[7, "Venus"], [13, "Mercury"], [20, "Jupiter"], [25, "Mars"], [30, "Saturn"]], + [[12, "Venus"], [16, "Jupiter"], [19, "Mercury"], [28, "Mars"], [30, "Saturn"]] + ], + "drekkana_lords": [ + ["Mars", "Sun", "Venus"], ["Mercury", "Moon", "Saturn"], + ["Jupiter", "Mars", "Sun"], ["Venus", "Mercury", "Moon"], + ["Saturn", "Jupiter", "Mars"], ["Sun", "Venus", "Mercury"], + ["Moon", "Saturn", "Jupiter"], ["Mars", "Sun", "Venus"], + ["Mercury", "Moon", "Saturn"], ["Jupiter", "Mars", "Sun"], + ["Venus", "Mercury", "Moon"], ["Saturn", "Jupiter", "Mars"] + ] +} diff --git a/scripts/consultation_native_layers.py b/scripts/consultation_native_layers.py index 33ca3345..d8ec1a26 100644 --- a/scripts/consultation_native_layers.py +++ b/scripts/consultation_native_layers.py @@ -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"), diff --git a/scripts/jyotish_engine.py b/scripts/jyotish_engine.py index 0ce6832b..2f9f6bbd 100644 --- a/scripts/jyotish_engine.py +++ b/scripts/jyotish_engine.py @@ -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 diff --git a/scripts/pl9_reader_english_terms.py b/scripts/pl9_reader_english_terms.py index 4c86c017..55e64680 100644 --- a/scripts/pl9_reader_english_terms.py +++ b/scripts/pl9_reader_english_terms.py @@ -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}宫相位" diff --git a/scripts/pl9_reader_export.py b/scripts/pl9_reader_export.py index 38907323..a966fdf5 100644 --- a/scripts/pl9_reader_export.py +++ b/scripts/pl9_reader_export.py @@ -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: diff --git a/scripts/solar_return.py b/scripts/solar_return.py index c7159eed..3d7ceca9 100644 --- a/scripts/solar_return.py +++ b/scripts/solar_return.py @@ -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)} diff --git a/scripts/tajika.py b/scripts/tajika.py index 0f62c118..b5099bf5 100644 --- a/scripts/tajika.py +++ b/scripts/tajika.py @@ -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, diff --git a/scripts/tajika_year_lord.py b/scripts/tajika_year_lord.py new file mode 100644 index 00000000..05d3e96a --- /dev/null +++ b/scripts/tajika_year_lord.py @@ -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 diff --git a/tests/test_report_reader_main.py b/tests/test_report_reader_main.py index e6d69618..ffb69734 100644 --- a/tests/test_report_reader_main.py +++ b/tests/test_report_reader_main.py @@ -234,9 +234,12 @@ def test_real_annual_producers_agree_without_identical_metadata(reader_packet) - field = annual["year_lord"] assert field["status"] == "partial_verified" left, right = [item["value"] for item in field["producers_checked"]] - assert "year_lord_sign" in left - assert "year_lord_sign" not in right + # 原值:左(太阳返照)有 year_lord_sign、右(Tajika)没有——元数据不同仍判一致 + # 新值:两个生产者共用 tajika_year_lord,键相同;断言选出的年主、候选与依据一致 + # 原因:BUG-1214 两个生产者改为同一 Panchadhikari 选法(任务书 T2) assert left["year_lord"] == right["year_lord"] + assert left["candidates"] == right["candidates"] + assert left["selection_basis"] == right["selection_basis"] assert field["data"] == left sections = annual["report_sections"] assert "冲突" not in repr(sections["executive_summary"]["summary_lines"]) @@ -252,7 +255,12 @@ def test_reader_annual_cells_use_real_distinct_years_and_keep_return_dates(reade assert "-" not in lords + signs assert len(set(lords)) >= 2 assert len(set(signs)) >= 2 - assert "| Selection basis | Muntha 所在星座的守护星(当前本地口径) |" in reader + # 原值:「| Selection basis | Muntha 所在星座的守护星(当前本地口径) |」(开工基线即已失败: + # BUG-1212 把该文案改成了「暂用 Muntha 主星…」,本条未跟改) + # 新值:每个年度都有 Panchadhikari 选法依据一句话 + # 原因:BUG-1214(任务书 T2) + bases = re.findall(r"^\| Selection basis \| ([^|]+) \|$", reader, re.M) + assert len(bases) == len(lords) and all("年盘上升" in basis for basis in bases) assert "| Annual ascendant sign | - |" not in reader timing = reader_packet["worksheets"]["timing_and_predictive_systems"] for year in ("2027", "2028"): @@ -274,7 +282,11 @@ def test_reader_annual_conflict_keeps_both_real_producer_values(reader_packet) - ) assert annual["year_lord"]["status"] == "conflict" reader = _pl9_export_markdown_for_edition(packet, "reader_main") - assert "| Varshesha | 太阳返照:火星;Tajika:金星(两者不同) |" in reader + # 原值:「太阳返照:火星;Tajika:金星(两者不同)」(当年 / 次年的 Muntha 主星) + # 新值:「太阳返照:火星;Tajika:月亮(两者不同)」(当年 / 次年按 Panchadhikari 选出的年主) + # 原因:BUG-1214 年主改为 Panchadhikari 选法(任务书 T2);本用例只测冲突两值都保留 + assert (current["year_lord"], later["year_lord"]) == ("Mars", "Moon") + assert "| Varshesha | 太阳返照:火星;Tajika:月亮(两者不同) |" in reader assert "两种计算口径给出的年主不同,暂不合并" in reader assert "| Muntha sign | 白羊座 |" in reader assert _leak_hits(reader) == [] diff --git a/tests/test_tajika_panchadhikari_year_lord.py b/tests/test_tajika_panchadhikari_year_lord.py new file mode 100644 index 00000000..3211c1ca --- /dev/null +++ b/tests/test_tajika_panchadhikari_year_lord.py @@ -0,0 +1,163 @@ +"""Tajika year lord by the Panchadhikari rule (BUG-1214, round-4 ruling Q3). + +`tajika.calc_panchadhikari_year_lord` is ported from upstream yinduzhanxing +c7117f92 together with `references/tajika_panchavargiya_rules.json`. Upstream's +`tests/test_tajika.py` has no Panchadhikari case and its CLI smoke cases need a +private local subject chart, so the rule is pinned here with synthetic +longitudes (selection branches) and a fictional chart (both producers agree). +""" + +from __future__ import annotations + +import hashlib +from pathlib import Path + +import pytest + +import scripts.tajika as tajika +from scripts.annual_tajika_pack import build_annual_tajika_pack +from scripts.tajika_year_lord import selection_basis_text + +ROOT = Path(__file__).resolve().parents[1] +UPSTREAM_RULES_BLOB = "e8c15ac31246a1b923f2d108729c319f8731d179" # c7117f92:references/tajika_panchavargiya_rules.json + + +@pytest.fixture(autouse=True) +def _native_panchavargiya(monkeypatch): + # Product path: native-proxy Panchavargiya unless the research opt-in is set. + monkeypatch.delenv("JYOTISH_YEAR_LORD_PYJHORA_REFERENCE", raising=False) + + +def _scores(monkeypatch, scores: dict) -> None: + def fake(planet, lon_value, **_kwargs): + return {"score": scores.get(planet, 0.0), "engine": "native_proxy", "components": {}} + monkeypatch.setattr(tajika, "_resolve_year_lord_panchavargiya_score", fake) + + +def _lon(sign_idx: int, deg: float = 10.0) -> float: + return sign_idx * 30.0 + deg + + +def test_rules_json_is_the_upstream_blob() -> None: + data = (ROOT / "references/tajika_panchavargiya_rules.json").read_bytes() + assert hashlib.sha1(b"blob %d\0" % len(data) + data).hexdigest() == UPSTREAM_RULES_BLOB + assert set(tajika._PANCHAVARGIYA_RULES) == {"source", "tajika_relation", "hudda", "drekkana_lords"} + + +# Annual Lagna Aries (0). Benefic Tajika aspects reach the Lagna from signs +# 3/5/9/11 away (5th/9th/3rd/11th), malefic from 1/4/7/10. +BASE = {"Sun": _lon(1), "Moon": _lon(1), "Mars": _lon(1), "Mercury": _lon(1), + "Jupiter": _lon(1), "Venus": _lon(1), "Saturn": _lon(1)} + + +def _select(planet_lons, **kwargs): + defaults = dict(annual_asc_sign_idx=0, natal_lagna_sign_idx=3, years_from_dob=0, muntha_sign_idx=6) + defaults.update(kwargs) + return tajika.calc_panchadhikari_year_lord(planet_lons, **defaults) + + +def test_benefic_shortlist_takes_the_strongest(monkeypatch) -> None: + # Candidates: natal Lagna lord Moon (Cancer), Muntha lord Venus (Libra), + # annual Lagna lord Mars, new-year lord and Tri-Rasi lord (day/night). + lons = {**BASE, "Moon": _lon(4), "Venus": _lon(8), "Mars": _lon(1)} # Moon in Leo (5th), Venus in Sag (9th) + _scores(monkeypatch, {"Moon": 12.0, "Venus": 15.0, "Mars": 20.0}) + result = _select(lons) + assert result["selection_basis"] == "panchadhikari_benefic_shortlist_pvb" + assert result["year_lord"] == "Venus" # Mars is stronger but casts no benefic aspect + rows = {row["planet"]: row for row in result["candidates"]} + assert rows["Moon"]["benefic_aspects_asc"] and rows["Venus"]["benefic_aspects_asc"] + assert not rows["Mars"]["benefic_aspects_asc"] + + +def test_single_benefic_wins_even_when_weak(monkeypatch) -> None: + lons = {**BASE, "Moon": _lon(2)} # Moon in Gemini: 3rd from Aries + _scores(monkeypatch, {"Moon": 1.0, "Mars": 20.0, "Venus": 20.0}) + result = _select(lons) + assert (result["year_lord"], result["selection_basis"]) == ("Moon", "panchadhikari_single_benefic") + + +def test_malefic_shortlist_when_no_benefic(monkeypatch) -> None: + lons = {**BASE, "Moon": _lon(3), "Venus": _lon(6), "Mars": _lon(1)} # Moon 4th, Venus 7th, Mars 2nd + _scores(monkeypatch, {"Moon": 9.0, "Venus": 11.0, "Mars": 30.0}) + result = _select(lons) + assert (result["year_lord"], result["selection_basis"]) == ("Venus", "panchadhikari_malefic_shortlist_pvb") + + +def test_strongest_of_all_when_no_candidate_aspects(monkeypatch) -> None: + lons = {**BASE, "Moon": _lon(1), "Venus": _lon(5), "Mars": _lon(7)} # 2nd / 6th / 8th: no Tajika aspect + lons = {planet: (lon if planet in ("Moon", "Venus", "Mars") else _lon(11)) for planet, lon in lons.items()} # others 12th + _scores(monkeypatch, {"Moon": 9.0, "Venus": 11.0, "Mars": 14.0, "Sun": 3.0, "Jupiter": 2.0, "Saturn": 1.0, "Mercury": 1.0}) + result = _select(lons) + assert result["selection_basis"] == "panchadhikari_pvb_fallback_all_candidates" + assert result["year_lord"] == max(result["candidates"], key=lambda row: row["panchavargiya_score"])["planet"] + + +def test_muntha_candidate_uses_the_supplied_natal_muntha(monkeypatch) -> None: + _scores(monkeypatch, {}) + lons = dict(BASE) + ours = _select(lons, years_from_dob=5, muntha_sign_idx=9) # natal Muntha Capricorn + upstream = _select(lons, years_from_dob=5, muntha_sign_idx=None) # upstream: annual Lagna + years = Virgo + assert next(row for row in ours["candidates"] if row["source"] == "muntha_lord")["planet"] == "Saturn" + assert next(row for row in upstream["candidates"] if row["source"] == "muntha_lord")["planet"] == "Mercury" + assert (ours["muntha_sign"], upstream["muntha_sign"]) == ("Capricorn", "Virgo") + + +@pytest.mark.parametrize("basis", [ + "panchadhikari_single_benefic", "panchadhikari_benefic_shortlist_pvb", "panchadhikari_single_malefic", + "panchadhikari_malefic_shortlist_pvb", "panchadhikari_pvb_fallback_all_candidates", +]) +def test_every_basis_has_a_sentence(basis: str) -> None: + zh, en = selection_basis_text({"selection_basis": basis, "year_lord": "Mars", "panchavargiya_engine": "native_proxy", + "candidates": [{"planet": "Mars", "benefic_aspects_asc": True, "malefic_aspects_asc": True}]}) + assert "Mars" in zh and zh.endswith("(Panchavargiya 为本地近似计算)") + assert "Mars" in en and en.endswith("(Panchavargiya is a local approximation)") + + +FICTIONAL = {"birth": {"date": "1990-01-01", "time": "12:00:00", "latitude": 39.9, "longitude": 116.4, "utc_offset": "+08:00"}, + "settings": {"ayanamsa": "raman"}} + + +@pytest.mark.parametrize("target_year", [2024, 2025, 2026]) +def test_both_annual_producers_select_the_same_year_lord(target_year: int) -> None: + pack = build_annual_tajika_pack({**FICTIONAL, "target_year": target_year}) + field = pack["year_lord"] + assert field["status"] == "partial_verified", field + values = [row["value"] for row in field["producers_checked"]] + assert {row["producer"] for row in field["producers_checked"]} == {"solar_return", "tajika"} + for value in values: + assert value["selection_basis"].startswith("panchadhikari_") + assert value["muntha_basis"] == "natal_ascendant_plus_completed_years" + assert value["selection_basis_text"] and value["selection_basis_text_en"] + assert {row["panchavargiya_engine"] for row in value["candidate_list"]} == {"native_proxy"} + assert sum(row["selected"] for row in value["candidate_list"]) == 1 + assert values[0]["candidates"] == values[1]["candidates"] + assert values[0]["year_lord"] == values[1]["year_lord"] + # Mudda Dasha starts from the selected year lord on both lanes. + assert pack["mudda_dasha"]["data"]["varsha_lord"] == values[0]["year_lord"] + + +def test_pyjhora_reference_runs_only_on_the_research_opt_in(monkeypatch) -> None: + """The product path never hands PyJHora (AGPL, absent from the API image) + the annual JD; the research opt-in restores the upstream lookup.""" + from scripts.solar_return import calc_solar_return_chart + from scripts.tajika_year_lord import PYJHORA_REFERENCE_ENV, select_annual_year_lord + + seen = [] + + def fake_external(planet, *, annual_jd, **_kwargs): + seen.append(annual_jd) + return None + + import sys + from scripts import tajika_year_lord + selector_module = sys.modules[tajika_year_lord.calc_panchadhikari_year_lord.__module__] + monkeypatch.setattr(selector_module, "_external_pyjhora_panchavargiya_score", fake_external) + sr = calc_solar_return_chart(1990, 1, 1, 12, 0, 39.9, 116.4, 8.0, 2026, ayanamsa_name="raman") + kwargs = dict(natal_asc_sign_idx=sr["birth_asc_sign_idx"], completed_years=36, ayanamsa_name="raman", + birth_lat=39.9, birth_lon=116.4, birth_tz=8.0) + select_annual_year_lord(sr, **kwargs) + assert seen and set(seen) == {None} + seen.clear() + monkeypatch.setenv(PYJHORA_REFERENCE_ENV, "1") + select_annual_year_lord(sr, **kwargs) + assert seen and set(seen) == {sr["solar_return"]["jd_ut"]} diff --git a/tests/test_year_lord_basis_label.py b/tests/test_year_lord_basis_label.py index bf1c0eed..cb773372 100644 --- a/tests/test_year_lord_basis_label.py +++ b/tests/test_year_lord_basis_label.py @@ -1,8 +1,10 @@ -"""Year lord is labelled as a Muntha-lord stand-in for Panchavargiya (BUG-1212). +"""Year lord shows its selection basis on every surface (BUG-1212 → BUG-1214). -Astrologer rulings 2026-10-03 甲11: Muntha = natal Lagna + completed years -(kept); year lord by Panchavargiya is not implemented (native producer blocked), -so every surface that shows the year lord says so. Fictional chart. +Astrologer rulings 2026-10-03 甲11 and round-4 Q3: Muntha = natal Lagna + +completed years (kept); the year lord is chosen by the Panchadhikari rule +(`tajika_year_lord`), and the card and both report editions carry its +one-sentence basis instead of the BUG-1212 "Muntha lord for now" label. +Fictional chart. """ from __future__ import annotations @@ -12,7 +14,9 @@ import re from datetime import datetime from pathlib import Path -from scripts.consultation_native_layers import YEAR_LORD_BASIS, build_annual_tajika +import pytest + +from scripts.consultation_native_layers import build_annual_tajika from scripts.pl9_reader_export import _pl9_export_markdown_for_edition from scripts.solar_return import SIGNS, solar_return_full_report from tests.test_report_english_edition import CASES, _packet @@ -20,14 +24,28 @@ from tests.test_report_english_edition import CASES, _packet ROOT = Path(__file__).resolve().parents[1] +@pytest.fixture(autouse=True) +def _native_panchavargiya(monkeypatch): + # Product path: native-proxy Panchavargiya unless the research opt-in is set. + monkeypatch.delenv("JYOTISH_YEAR_LORD_PYJHORA_REFERENCE", raising=False) + + def test_report_shows_the_basis_in_both_editions() -> None: + # 原值:两版都断言「暂用 Muntha 主星;占星师口径为 Panchavargiya,待接入」 + # 新值:断言 Panchadhikari 选法依据一句话(中文版含「年盘上升」与「本地近似计算」, + # 英文版是同一句的英文、不含中文) + # 原因:BUG-1214 接入 Panchadhikari 年主选法,去掉 BUG-1212 的暂用标注(任务书 T2) packet = _packet(CASES["day"]) zh = _pl9_export_markdown_for_edition(copy.deepcopy(packet), "reader_main") english = copy.deepcopy(packet) english["report_language"] = "en" en = _pl9_export_markdown_for_edition(english, "reader_main") - assert zh.count("| Selection basis | 暂用 Muntha 主星;占星师口径为 Panchavargiya,待接入 |") >= 1 - assert "| Selection basis | Muntha sign lord for now; the astrologer's rule is Panchavargiya, not yet wired |" in en + zh_rows = [line for line in zh.splitlines() if line.startswith("| Selection basis |")] + en_rows = [line for line in en.splitlines() if line.startswith("| Selection basis |")] + assert zh_rows and all("年盘上升" in row and "Panchavargiya 为本地近似计算" in row for row in zh_rows) + assert en_rows and all("annual Lagna" in row and "local approximation" in row for row in en_rows) + assert not re.search(r"[\u4e00-\u9fff]", "\n".join(en_rows)) + assert "暂用 Muntha 主星" not in zh assert zh.count("| Varshesha |") == zh.count("| Selection basis |") @@ -39,7 +57,11 @@ def test_consultation_annual_card_carries_the_basis() -> None: case["lat"], case["lon"], case["tz"], ayanamsa_name=case["ayanamsa"]) annual = build_annual_tajika(payload, chart, reference=datetime(2026, 10, 3)) assert annual["status"] == "parameter_sensitive" - assert annual["varshesha_basis"] == YEAR_LORD_BASIS + # 原值:varshesha_basis == YEAR_LORD_BASIS(「暂用 Muntha 主星…待接入」) + # 新值:varshesha_basis 是年主选法依据一句话(tajika_year_lord.selection_basis_text) + # 原因:BUG-1214(任务书 T2) + assert "年盘上升" in annual["varshesha_basis"] and "暂用" not in annual["varshesha_basis"] + assert annual["varshesha_basis"].endswith("(Panchavargiya 为本地近似计算)") card = (ROOT / "frontend/src/lib/consultation-evidence-card.ts").read_text(encoding="utf-8") assert '"varshesha", "varshesha_basis"' in card workflow = (ROOT / "frontend/src/mastra/consultation-workflow.ts").read_text(encoding="utf-8") @@ -59,7 +81,15 @@ def test_main_path_muntha_is_natal_lagna_plus_completed_years() -> None: assert (muntha["natal_asc_sign_idx"], muntha["age"]) == (natal_asc, completed) assert muntha["muntha_sign_idx"] == (natal_asc + completed) % 12 assert muntha["muntha_sign"] == SIGNS[muntha["muntha_sign_idx"]] - assert report["year_lord"]["year_lord"] == muntha["muntha_lord"] + # 原值:年主 == Muntha 主星 + # 新值:年主由 Panchadhikari 选出;Muntha 主星一定在候选里(来源 muntha_lord), + # 候选 Muntha 用的是「本命上升 + 已满年数」 + # 原因:BUG-1214(第四轮问 3) + year_lord = report["year_lord"] + assert year_lord["muntha_basis"] == "natal_ascendant_plus_completed_years" + assert year_lord["muntha_sign_idx"] == muntha["muntha_sign_idx"] + planets = {row["planet"] for row in year_lord["candidates"]} + assert muntha["muntha_lord"] in planets and year_lord["year_lord"] in planets def test_sun_sign_muntha_variant_is_off_the_main_path() -> None: