diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index df2e6192..1917f737 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -36623,3 +36623,18 @@ - 相关记录:无。 - 复发自:无。 - 修复版本:分支 `codex/upstream-sync8-20261017`。上游提交 `9f9f16b3`、`872eaecf`。 + +## BUG-1389 | Avastha 友敌恒为假,落陷只认精确点,Shayanadi 按元素猜 + +- 状态:resolved +- 首次发现 / 最近更新:2026-10-17 / 2026-10-18 +- 来源:issue #80,任务书 T8。上游 `e628118d` 只取 Avastha 部分,以及 `7d545039`、`f75a6e77`、`ac565836`。检索「Deeptadi」「Shayanadi」,无既有编号。 +- 现象:友星座和敌星座判定恒为假。旺、落只认精确度数前后 5°。年龄状态不按奇偶宫翻转。警觉状态按宫位,交点星也套七曜规则。标签把落陷写成残缺、燃烧写成恶意。姿态状态按星座元素猜测。完整解读没有把月亮、上升和日出后小时数送进公式。 +- 触发条件:`AvasthaCalculator.calculate_all_avasthas`,以及 `cmd_full_reading` 的 Avastha 步骤。 +- 根因:友敌函数是空实现。旺落用度数窗口。Shayanadi 没有 PVR 公式,主链也没有日出上下文。 +- 修复:友敌读 `shat_bala_constants.json` 的自然关系。旺落按整座。年龄状态按奇偶宫翻转,老年不编分数。警觉按尊贵,交点标未分类。Deeptadi 按 Science of Light:落陷 Khala、燃烧 Kopa、友 Shanta、中性 Dina、敌 Duhkhita。并存条件全部留下;单标签标明「无经典出处」;大友和凶星同宫标明「未计算」。Shayanadi 在主链改用 PVR 公式,小时数来自前一次 Hindu 日出。报告表和英文表在有并存条件时把它们拼进 Deeptadi 格。 +- 验证:`tests/test_avastha_dignity_regressions.py`、`tests/test_shayanadi_pvr.py`、`tests/test_avastha_full_reading_wiring.py`。前端 `tests/report-fact-tables.test.ts` 11 项通过,含中英文并存条件格。 +- 防复发:无活动上下文时 Shayanadi 仍标 `simplified_heuristic`;主链传入上下文后必须是 `pvr_15_4_4_running_ghati`。 +- 相关记录:BUG-1388。 +- 复发自:无。 +- 修复版本:分支 `codex/upstream-sync8-20261017`。上游提交 `e628118d`、`7d545039`、`f75a6e77`、`ac565836`。 diff --git a/frontend/src/lib/report-fact-tables.ts b/frontend/src/lib/report-fact-tables.ts index 0dd92df5..57a7bd23 100644 --- a/frontend/src/lib/report-fact-tables.ts +++ b/frontend/src/lib/report-fact-tables.ts @@ -169,7 +169,14 @@ export function assembleReportFactTables(packet: unknown, language: ReportLangua } else if (id === "avasthas") { for (const [name, value] of entries(raw)) { if (!planet(name) || isBlocked(value)) continue; - add(table, `${sourcePath}.${name}`, [L.planet(name, record(value).planet_cn), ...["Bala", "Jagrat", "Deeptadi", "Lajjitadi", "Shayanadi"].map(kind => L.text(at(value, `avasthas.${kind}.state`)))]); + const cells = ["Bala", "Jagrat", "Deeptadi", "Lajjitadi", "Shayanadi"].map(kind => { + const item = record(at(value, `avasthas.${kind}`)); + if (kind === "Deeptadi" && Array.isArray(item.applicable_states) && item.applicable_states.length) { + return item.applicable_states.join("、"); + } + return L.text(item.state); + }); + add(table, `${sourcePath}.${name}`, [L.planet(name, record(value).planet_cn), ...cells]); } } else if (id === "special_points") { for (const kind of ["upagrahas.raw", "special_lagnas", "sensitive_points"]) { diff --git a/frontend/tests/report-fact-tables.test.ts b/frontend/tests/report-fact-tables.test.ts index 351dc4cc..b072bf4d 100644 --- a/frontend/tests/report-fact-tables.test.ts +++ b/frontend/tests/report-fact-tables.test.ts @@ -140,7 +140,13 @@ test("density golden: state and position cells retain actual source values", () const states = tables.find(t => t.id === "avasthas")!; for (const row of states.rows) { const raw = resolve(row.sourcePath); - assert.deepEqual(row.cells.slice(1), ["Bala", "Jagrat", "Deeptadi", "Lajjitadi", "Shayanadi"].map(key => raw.avasthas[key].state)); + // 原值:五列都取 .state。新值:Deeptadi 有 applicable_states 时用「、」拼出并存条件,否则仍取 .state。原因:落陷与燃烧等同时成立的条件要留在表上,单标签只是兼容字段。 + assert.deepEqual(row.cells.slice(1), ["Bala", "Jagrat", "Deeptadi", "Lajjitadi", "Shayanadi"].map(key => { + const item = raw.avasthas[key]; + return key === "Deeptadi" && Array.isArray(item.applicable_states) && item.applicable_states.length + ? item.applicable_states.join("、") + : item.state; + })); } for (const grid of [tables.find(t => t.id === "special_points")!, ...tables.find(t => t.id === "annual")!.subtables!]) { for (const row of grid.rows) { @@ -188,3 +194,16 @@ test("density blocked sources stay blocked and missing annual positions are not assert.match(tables.find(t => t.id === "annual")!.note, /年度上升:天蝎;年龄:26\.00/); assert.match(tables.find(t => t.id === "annual")!.note, /未返回年度行星位置,不补算/); }); + +test("avastha table shows simultaneous Deeptadi conditions in Chinese and English", () => { + const altered = structuredClone(packet); + const sun = altered.worksheets.advanced_systems.avasthas.Sun; + sun.avasthas.Deeptadi.state = "Deepta"; + sun.avasthas.Deeptadi.applicable_states = ["Deepta", "Kopa"]; + for (const language of ["zh", "en"] as const) { + const table = assembleReportFactTables(altered, language).find(item => item.id === "avasthas")!; + assert.equal(table.columns[3], "Deeptadi"); + const row = table.rows.find(item => item.sourcePath.endsWith(".Sun"))!; + assert.equal(row.cells[3], "Deepta、Kopa"); + } +}); diff --git a/scripts/avastha_calculator.py b/scripts/avastha_calculator.py index 217894f5..574b3bed 100644 --- a/scripts/avastha_calculator.py +++ b/scripts/avastha_calculator.py @@ -19,6 +19,60 @@ Avastha Calculator - 行星状态计算器 from typing import Dict, List, Tuple from enum import Enum +import json +import math +from bisect import bisect_right +from pathlib import Path + +with (Path(__file__).resolve().parents[1] / 'references' / 'shat_bala_constants.json').open(encoding='utf-8') as _handle: + NATURAL_RELATIONSHIPS = json.load(_handle)['natural_relationships'] +SIGNS = ['Aries', 'Taurus', 'Gemini', 'Cancer', 'Leo', 'Virgo', + 'Libra', 'Scorpio', 'Sagittarius', 'Capricorn', 'Aquarius', 'Pisces'] + + +def calculate_pvr_shayanadi(planet, longitude, moon_longitude, asc_longitude, hours_since_sunrise): + """PVR §15.4.4/Table 36; running ghati, not elapsed fractional ghatis. + + Activity intensity requires the name's Sanskrit first-sound index and is + deliberately not guessed from an English display name. + """ + order = ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Rahu', 'Ketu') + if planet not in order: + raise ValueError('Unsupported Shayanadi planet') + values = (longitude, moon_longitude, asc_longitude, hours_since_sunrise) + if any(isinstance(value, bool) or not math.isfinite(float(value)) for value in values): + raise ValueError('Finite numeric Shayanadi inputs required') + if hours_since_sunrise < 0: + raise ValueError('Use the preceding sunrise, not a future sunrise') + longitude, moon_longitude, asc_longitude = (float(value) % 360 for value in values[:3]) + boundaries = [i * 40 / 3 for i in range(1, 27)] + c = bisect_right(boundaries, longitude) + 1 + p = order.index(planet) + 1 + a = bisect_right([i * 10 / 3 for i in range(1, 9)], longitude % 30) + 1 + m = bisect_right(boundaries, moon_longitude) + 1 + g = math.floor(hours_since_sunrise * 2.5) + 1 + l = int(asc_longitude // 30) + 1 + total = c * p * a + m + g + l + index = (total - 1) % 12 + 1 + states = ('Shayana', 'Upaveshana', 'Netrapani', 'Prakashana', 'Gamana', 'Agamana', + 'Sabha', 'Agama', 'Bhojana', 'Nrityalipsa', 'Kautuka', 'Nidra') + return { + 'state': states[index - 1], + 'state_index': index, + 'sign': SIGNS[int(longitude // 30)], + 'meaning': 'Activity state from the PVR constellation, navamsa and running-ghati formula.', + 'method_status': 'pvr_15_4_4_running_ghati', + 'components': { + 'planet_nakshatra': c, + 'planet_index': p, + 'navamsa_part': a, + 'moon_nakshatra': m, + 'running_ghati': g, + 'lagna_sign_number': l, + 'formula_sum': total, + }, + 'activity_intensity_status': 'requires_name_first_sound_index', + } class BalaAvastha(Enum): """年龄状态(5种生命阶段)""" @@ -39,11 +93,12 @@ class DeeptadiAvastha(Enum): DEEPTA = "Deepta" # 光辉(Exalted) SWASTHA = "Swastha" # 舒适(Own Sign) MUDITA = "Mudita" # 快乐(Friend's Sign) - SHANTA = "Shanta" # 平静(Neutral Sign) - DINA = "Dina" # 沮丧(Enemy Sign) - VIKALA = "Vikala" # 残缺(Debilitated) - KHALA = "Khala" # 恶意(Combust) - KOPA = "Kopa" # 愤怒(Defeated in War) + SHANTA = "Shanta" # 平静(Friend's Sign) + DINA = "Dina" # 低落(Neutral Sign) + DUHKHITA = "Duhkhita" # 困苦(Enemy Sign) + VIKALA = "Vikala" # 残缺(Joined by malefics) + KHALA = "Khala" # 负担(Debilitated; Science of Light) + KOPA = "Kopa" # 愤怒(Combust) GARVITA = "Garvita" # 骄傲(Victorious in War) KSHUDITA = "Kshudita" # 饥饿(In Sandhi) TRUSHITA = "Trushita" # 口渴(In Gandanta) @@ -127,7 +182,7 @@ class AvasthaCalculator: def calculate_all_avasthas(self, planet: str, sign: str, degree: float, house: int, conjunctions: List[str] = None, aspects: List[str] = None, is_combust: bool = False, - is_retrograde: bool = False) -> Dict[str, any]: + is_retrograde: bool = False, activity_context: Dict = None) -> Dict[str, any]: """ 计算行星的所有Avastha状态 @@ -153,7 +208,7 @@ class AvasthaCalculator: } # 1. Bala Avastha(年龄状态) - result["avasthas"]["Bala"] = self._calculate_bala_avastha(degree) + result["avasthas"]["Bala"] = self._calculate_bala_avastha(degree, sign) # 2. Jagrat Avastha(警觉状态) result["avasthas"]["Jagrat"] = self._calculate_jagrat_avastha(planet, sign, house) @@ -170,7 +225,25 @@ class AvasthaCalculator: # 5. Shayanadi Avastha(姿态状态) result["avasthas"]["Shayanadi"] = self._calculate_shayanadi_avastha(planet, sign) - + for name in ('Deeptadi', 'Lajjitadi', 'Shayanadi'): + result['avasthas'][name]['method_status'] = 'simplified_heuristic' + if activity_context is not None: + result['avasthas']['Shayanadi'] = calculate_pvr_shayanadi( + planet, + SIGNS.index(sign) * 30 + degree, + activity_context['moon_longitude'], + activity_context['asc_longitude'], + activity_context['hours_since_sunrise'], + ) + result['inputs'] = { + 'conjunctions': conjunctions or [], + 'aspects': aspects or [], + 'is_combust': is_combust, + 'is_retrograde': is_retrograde, + } + if activity_context is not None: + result['inputs']['activity_context'] = dict(activity_context) + # 6. 综合解读 result["interpretation"] = self._generate_interpretation(result["avasthas"]) @@ -179,11 +252,17 @@ class AvasthaCalculator: return result - def _calculate_bala_avastha(self, degree: float) -> Dict: + def _calculate_bala_avastha(self, degree: float, sign: str = 'Aries') -> Dict: """计算Bala Avastha(年龄状态)""" + if not 0 <= degree < 30: + raise ValueError('Avastha degree must be within [0, 30)') + original_degree = degree + even_sign = SIGNS.index(sign) % 2 == 1 + band = int(degree // 6) + degree = (4 - band if even_sign else band) * 6 if degree < 6: state = BalaAvastha.INFANT - strength = 0.2 + strength = 0.25 meaning = "婴儿期:行星力量微弱,潜力未开发,需要时间成长" elif degree < 12: state = BalaAvastha.YOUNG @@ -195,40 +274,46 @@ class AvasthaCalculator: meaning = "青年期:行星力量最强,充满活力,能够充分发挥作用" elif degree < 24: state = BalaAvastha.OLD - strength = 0.7 + strength = None meaning = "老年期:行星力量开始衰退,经验丰富但活力下降" else: state = BalaAvastha.DEAD - strength = 0.3 + strength = 0.0 meaning = "死亡期:行星力量极弱,难以发挥作用,需要补救" - + return { "state": state.value, - "degree_range": f"{int(degree//6)*6}-{int(degree//6)*6+6}°", + "degree_range": f"{int(original_degree//6)*6}-{int(original_degree//6)*6+6}°", "strength": strength, + "result_grade": 'negligible' if state == BalaAvastha.OLD else 'fraction', + "sign_parity": 'even' if even_sign else 'odd', + "method_status": 'bphs_age_sign_parity', "meaning": meaning } def _calculate_jagrat_avastha(self, planet: str, sign: str, house: int) -> Dict: """计算Jagrat Avastha(警觉状态)""" - # 简化规则: - # Awake: 在角宫(1,4,7,10) - # Dreaming: 在续宫(2,5,8,11) - # Deep Sleep: 在果宫(3,6,9,12) - - if house in [1, 4, 7, 10]: + if planet not in self.PLANET_SIGNS: + return { + 'state': 'Not classified', + 'house': house, + 'method_status': 'node_dignity_not_assigned', + 'meaning': '交点未指定尊贵度流派,不套用七曜警觉状态规则', + } + if self._is_own_sign(planet, sign) or self._is_exalted(planet, sign, 0): state = JagratAvastha.AWAKE meaning = "清醒状态:行星完全活跃,能够主动发挥作用,影响显著" - elif house in [2, 5, 8, 11]: + elif not self._is_enemy_sign(planet, sign) and not self._is_debilitated(planet, sign, 0): state = JagratAvastha.DREAMING meaning = "梦境状态:行星半活跃,作用不稳定,时强时弱" else: state = JagratAvastha.DEEP_SLEEP meaning = "深睡状态:行星不活跃,难以发挥作用,影响微弱" - + return { "state": state.value, "house": house, + "method_status": 'bphs_dignity_natural_relationships', "meaning": meaning } @@ -236,29 +321,50 @@ class AvasthaCalculator: is_combust: bool, aspects: List[str] = None) -> Dict: """计算Deeptadi Avastha(情绪状态)""" aspects = aspects or [] - - # 检查入庙/落陷 + if planet not in self.PLANET_SIGNS: + return { + 'state': 'Not classified', + 'meaning': '该口径未定义此天体的状态', + 'aspect_effects': [], + 'label_profile': 'science_of_light_deeptadi_natural_relationship_subset', + 'claim_boundary': 'Classical seven-planet profile; no nodal dignity is assumed.', + } + conditions = { + 'exalted': self._is_exalted(planet, sign, degree), + 'debilitated': self._is_debilitated(planet, sign, degree), + 'combust': bool(is_combust), + 'own_sign': self._is_own_sign(planet, sign), + 'natural_friend_sign': self._is_friend_sign(planet, sign), + 'natural_enemy_sign': self._is_enemy_sign(planet, sign), + } + applicable_states = [label for key, label in ( + ('exalted', 'Deepta'), ('debilitated', 'Khala'), ('combust', 'Kopa'), + ('own_sign', 'Swastha'), ('natural_friend_sign', 'Shanta'), + ('natural_enemy_sign', 'Duhkhita')) if conditions[key]] + if not any(conditions[key] for key in ('own_sign', 'natural_friend_sign', 'natural_enemy_sign')): + applicable_states.append('Dina') + # 单标签顺序是兼容字段,无经典出处。并存条件全部留在 applicable_states。 if self._is_exalted(planet, sign, degree): state = DeeptadiAvastha.DEEPTA meaning = "光辉状态:行星处于最佳位置,能量充沛,带来积极结果" elif self._is_debilitated(planet, sign, degree): - state = DeeptadiAvastha.VIKALA - meaning = "残缺状态:行星处于最弱位置,能量受损,带来挑战" - elif is_combust: state = DeeptadiAvastha.KHALA - meaning = "恶意状态:行星被太阳燃烧,能量被压制,难以正常发挥" + meaning = "负担状态:行星落陷;采用 Science of Light 的 Khala 定义" + elif is_combust: + state = DeeptadiAvastha.KOPA + meaning = "愤怒状态:行星受太阳燃烧;对应 Kopa" elif self._is_own_sign(planet, sign): state = DeeptadiAvastha.SWASTHA meaning = "舒适状态:行星在自己的星座,感觉自在,能够稳定发挥" elif self._is_friend_sign(planet, sign): - state = DeeptadiAvastha.MUDITA - meaning = "快乐状态:行星在友好星座,得到支持,能够良好发挥" - elif self._is_enemy_sign(planet, sign): - state = DeeptadiAvastha.DINA - meaning = "沮丧状态:行星在敌对星座,受到压制,发挥受限" - else: state = DeeptadiAvastha.SHANTA - meaning = "平静状态:行星在中性星座,不强不弱,平稳发挥" + meaning = "平静状态:行星在自然友星的星座;对应 Shanta" + elif self._is_enemy_sign(planet, sign): + state = DeeptadiAvastha.DUHKHITA + meaning = "困苦状态:行星在自然敌星的星座;对应 Duhkhita" + else: + state = DeeptadiAvastha.DINA + meaning = "低落状态:行星在自然中性星的星座;对应 Dina" # 检查相位影响 aspect_effects = [] @@ -274,7 +380,15 @@ class AvasthaCalculator: return { "state": state.value, "meaning": meaning, - "aspect_effects": aspect_effects + "aspect_effects": aspect_effects, + "label_profile": "science_of_light_deeptadi_natural_relationship_subset", + "conditions": conditions, + "applicable_states": applicable_states, + "legacy_single_label_note": "无经典出处", + "uncomputed_conditions": ["great_friend", "malefic_conjunction"], + "uncomputed_label": "未计算", + "source": "Science of Light, source p475; Khala is debility in this profile", + "claim_boundary": "Single-label priority is a legacy heuristic. Natural friendship only; great-friend and simultaneous conjunction states are not fully calculated. Khala follows Science of Light debility, not the alternative PVR malefic-sign definition.", } def _calculate_lajjitadi_avastha(self, planet: str, sign: str, house: int, @@ -357,14 +471,14 @@ class AvasthaCalculator: """检查是否入庙""" if planet in self.EXALTATION: exalt_sign, exalt_degree = self.EXALTATION[planet] - return sign == exalt_sign and abs(degree - exalt_degree) < 5 + return sign == exalt_sign return False def _is_debilitated(self, planet: str, sign: str, degree: float) -> bool: """检查是否落陷""" if planet in self.DEBILITATION: debil_sign, debil_degree = self.DEBILITATION[planet] - return sign == debil_sign and abs(degree - debil_degree) < 5 + return sign == debil_sign return False def _is_own_sign(self, planet: str, sign: str) -> bool: @@ -372,14 +486,14 @@ class AvasthaCalculator: return sign in self.PLANET_SIGNS.get(planet, []) def _is_friend_sign(self, planet: str, sign: str) -> bool: - """检查是否在友好星座(简化版)""" - # 这里需要完整的行星友好关系表,暂时简化 - return False + """检查是否在自然友星星座。""" + lord = next((name for name, signs in self.PLANET_SIGNS.items() if sign in signs), None) + return lord in NATURAL_RELATIONSHIPS.get(planet, {}).get('friends', []) def _is_enemy_sign(self, planet: str, sign: str) -> bool: - """检查是否在敌对星座(简化版)""" - # 这里需要完整的行星敌对关系表,暂时简化 - return False + """检查是否在自然敌星星座。""" + lord = next((name for name, signs in self.PLANET_SIGNS.items() if sign in signs), None) + return lord in NATURAL_RELATIONSHIPS.get(planet, {}).get('enemies', []) def _is_enemy_planet(self, planet1: str, planet2: str) -> bool: """检查两个行星是否敌对(简化版)""" @@ -437,7 +551,7 @@ class AvasthaCalculator: # 根据Bala Avastha bala_strength = avasthas["Bala"]["strength"] - if bala_strength < 0.5: + if bala_strength is not None and bala_strength < 0.5: remedies.append(f"行星处于弱势年龄阶段,建议佩戴{planet}对应的宝石") remedies.append(f"在{planet}主管的日子进行布施和祈祷") @@ -447,7 +561,7 @@ class AvasthaCalculator: # 根据Deeptadi Avastha deeptadi_state = avasthas["Deeptadi"]["state"] - if deeptadi_state in ["Vikala", "Khala", "Dina"]: + if deeptadi_state in ["Vikala", "Khala", "Duhkhita", "Kopa"]: remedies.append(f"行星处于{deeptadi_state}状态,需要特别补救") remedies.append(f"念诵{planet}的Beeja Mantra(种子咒)") diff --git a/scripts/jyotish_engine.py b/scripts/jyotish_engine.py index c5411b92..a7be4b8e 100644 --- a/scripts/jyotish_engine.py +++ b/scripts/jyotish_engine.py @@ -16369,6 +16369,15 @@ def cmd_full_reading(args): # ── Step 10.5: Avasthas 行星状态 (v4.4.0) ── try: from avastha_calculator import AvasthaCalculator + from saham_daynight import determine_daytime + avastha_solar_day = determine_daytime( + _birth_datetime_from_args(args), lat=args.lat, lon=args.lon, tz=args.tz, rising='hindu') + hours_since_sunrise = (avastha_solar_day['julian_day_ut'] - avastha_solar_day['sunrise_jd_ut']) * 24 + avastha_activity_context = { + 'moon_longitude': planet_lons['Moon'], + 'asc_longitude': asc_deg, + 'hours_since_sunrise': hours_since_sunrise, + } avast_calc = AvasthaCalculator() avast_result = {} for pn, pd in planets.items(): @@ -16381,7 +16390,10 @@ def cmd_full_reading(args): if isinstance(pd2, dict) and pd2.get('house') == house and p2 != pn] avast_result[pn] = avast_calc.calculate_all_avasthas( planet=pn, sign=sign, degree=deg_in_sign, - house=house, conjunctions=conjunctions) + house=house, conjunctions=conjunctions, + is_combust=bool(pd.get('combust', False)), + is_retrograde=bool(pd.get('retrograde', False)), + activity_context=avastha_activity_context) report['modules']['avasthas'] = avast_result except Exception as e: report['errors'].append(f"avasthas: {e}") diff --git a/scripts/pl9_reader_export.py b/scripts/pl9_reader_export.py index f0ec9f66..2f5eb1a1 100644 --- a/scripts/pl9_reader_export.py +++ b/scripts/pl9_reader_export.py @@ -1442,6 +1442,9 @@ def render_pl9_parity_markdown(packet: dict) -> str: continue def _state(name: str) -> str: state = _dict(states.get(name)) + applicable = state.get('applicable_states') if name == 'Deeptadi' else None + if isinstance(applicable, list) and applicable: + return _cell('、'.join(str(item) for item in applicable)) return _cell(state.get('state')) lines.append( f"| {_cell(_token(planet))} | {_state('Bala')} | {_state('Jagrat')} | {_state('Deeptadi')} | " diff --git a/tests/test_avastha_dignity_regressions.py b/tests/test_avastha_dignity_regressions.py new file mode 100644 index 00000000..647afbaa --- /dev/null +++ b/tests/test_avastha_dignity_regressions.py @@ -0,0 +1,64 @@ +"""Classical age/parity and dignity rules, independent of house placement.""" +import sys +from pathlib import Path +import pytest +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'scripts')) +from avastha_calculator import AvasthaCalculator + + +def test_deeptadi_keeps_exaltation_and_combustion_together(): + row = AvasthaCalculator().calculate_all_avasthas( + 'Venus', 'Pisces', 27, 1, is_combust=True)['avasthas']['Deeptadi'] + assert row['state'] == 'Deepta' + assert 'Deepta' in row['applicable_states'] + assert 'Kopa' in row['applicable_states'] + assert row['conditions']['combust'] is True + assert row['legacy_single_label_note'] == '无经典出处' + assert row['uncomputed_label'] == '未计算' + + +@pytest.mark.parametrize('sign,degree,state,strength', [ + ('Aries', 0, 'Infant', .25), ('Aries', 6, 'Young', .5), + ('Aries', 12, 'Youth', 1), ('Aries', 18, 'Old', None), + ('Aries', 24, 'Dead', 0), ('Taurus', 0, 'Dead', 0), + ('Taurus', 6, 'Old', None), ('Taurus', 12, 'Youth', 1), + ('Taurus', 18, 'Young', .5), ('Taurus', 24, 'Infant', .25), +]) +def test_age_sign_parity_and_exact_boundaries(sign, degree, state, strength): + row = AvasthaCalculator().calculate_all_avasthas('Sun', sign, degree, 9)['avasthas']['Bala'] + assert row['state'] == state and row['strength'] == strength + + +@pytest.mark.parametrize('planet,sign,expected', [ + ('Sun', 'Aries', 'Awake'), ('Venus', 'Pisces', 'Awake'), + ('Mars', 'Cancer', 'Deep Sleep'), ('Saturn', 'Aquarius', 'Awake'), + ('Sun', 'Capricorn', 'Deep Sleep'), ('Sun', 'Sagittarius', 'Dreaming'), + ('Sun', 'Gemini', 'Dreaming'), ('Rahu', 'Aries', 'Not classified'), +]) +def test_jagrat_follows_dignity_not_house(planet, sign, expected): + calculator = AvasthaCalculator() + for house in (1, 2, 9): + row = calculator.calculate_all_avasthas(planet, sign, 4.95, house)['avasthas']['Jagrat'] + assert row['state'] == expected + + +def test_simplified_states_cannot_masquerade_as_full_classical_methods(): + states = AvasthaCalculator().calculate_all_avasthas('Sun', 'Aries', 4.95, 9)['avasthas'] + for name in ('Deeptadi', 'Lajjitadi', 'Shayanadi'): + assert states[name]['method_status'] == 'simplified_heuristic' + assert states['Deeptadi']['state'] == 'Deepta' + + +@pytest.mark.parametrize('planet,sign,combust,expected', [ + ('Sun', 'Sagittarius', False, 'Shanta'), + ('Sun', 'Gemini', False, 'Dina'), + ('Sun', 'Capricorn', False, 'Duhkhita'), + ('Mars', 'Cancer', False, 'Khala'), + ('Venus', 'Gemini', True, 'Kopa'), +]) +def test_deeptadi_names_follow_declared_source_not_mislabelled_conditions(planet, sign, combust, expected): + row = AvasthaCalculator().calculate_all_avasthas( + planet, sign, 4.0, 1, is_combust=combust)['avasthas']['Deeptadi'] + assert row['state'] == expected + assert row['label_profile'] == 'science_of_light_deeptadi_natural_relationship_subset' + assert row['method_status'] == 'simplified_heuristic' diff --git a/tests/test_avastha_full_reading_wiring.py b/tests/test_avastha_full_reading_wiring.py new file mode 100644 index 00000000..54419eb9 --- /dev/null +++ b/tests/test_avastha_full_reading_wiring.py @@ -0,0 +1,39 @@ +"""Integration contract for the full-reading Avastha producer.""" +import ast +from datetime import datetime +from pathlib import Path +from types import SimpleNamespace + + +def test_full_reading_wires_hindu_sunrise_into_activity_formula(monkeypatch): + repo = Path(__file__).resolve().parents[1] + monkeypatch.syspath_prepend(str(repo / 'scripts')) + import saham_daynight + seen = {} + + def fake_daytime(*args, **kwargs): + seen['rising'] = kwargs.get('rising') + return {'julian_day_ut': 2451545.0, 'sunrise_jd_ut': 2451545.0 - 17 / 24} + + monkeypatch.setattr(saham_daynight, 'determine_daytime', fake_daytime) + tree = ast.parse((repo / 'scripts/jyotish_engine.py').read_text(encoding='utf-8')) + blocks = [node for node in ast.walk(tree) if isinstance(node, ast.Try) + and any(isinstance(item, ast.ImportFrom) and item.module == 'avastha_calculator' + for item in node.body)] + assert len(blocks) == 1 + report = {'modules': {}, 'errors': []} + env = dict( + report=report, + args=SimpleNamespace(lat=0, lon=0, tz=0), + asc_deg=0, + planet_lons={'Sun': 0, 'Moon': 0}, + planets={'Sun': {'degree': 0, 'degree_raw': 0, 'sign': 'Aries', 'house': 1}}, + _birth_datetime_from_args=lambda args: datetime(2000, 1, 1), + ) + exec(compile(ast.Module(body=blocks, type_ignores=[]), '', 'exec'), env) + assert report['errors'] == [] + assert seen['rising'] == 'hindu' + actual = report['modules']['avasthas']['Sun']['avasthas']['Shayanadi'] + assert actual['method_status'] == 'pvr_15_4_4_running_ghati' + assert actual['components']['running_ghati'] == 43 + assert actual['state_index'] == 10 diff --git a/tests/test_shayanadi_pvr.py b/tests/test_shayanadi_pvr.py new file mode 100644 index 00000000..ae444b25 --- /dev/null +++ b/tests/test_shayanadi_pvr.py @@ -0,0 +1,43 @@ +import math +import pytest +from scripts.avastha_calculator import calculate_pvr_shayanadi, AvasthaCalculator + + +def test_pvr_footnotes_navamsa_part_and_running_ghati(): + # Printed Mercury 22Ge14 is the seventh *part*, not its D9 sign. + # Sunrise 06:00, birth 23:00: 42.5 elapsed ghatis, 43rd running. + row = calculate_pvr_shayanadi('Mercury', 82 + 14 / 60, 0, 0, 17) + assert row['components'] == dict( + planet_nakshatra=7, planet_index=4, + navamsa_part=7, moon_nakshatra=1, running_ghati=43, + lagna_sign_number=1, formula_sum=241, + ) + assert row['state'] == 'Shayana' + + +def test_zero_remainder_is_twelfth_state(): + # 1*1*1 + 1 + 9 + 1 = 12. + assert calculate_pvr_shayanadi('Sun', 0, 0, 0, 3.2)['state'] == 'Nidra' + + +def test_exact_navamsa_and_ghati_boundaries(): + edge = 10 / 3 + assert calculate_pvr_shayanadi('Sun', math.nextafter(edge, -math.inf), 0, 0, 0)['components']['navamsa_part'] == 1 + assert calculate_pvr_shayanadi('Sun', edge, 0, 0, 0)['components']['navamsa_part'] == 2 + assert calculate_pvr_shayanadi('Sun', 0, 0, 0, .4)['components']['running_ghati'] == 2 + + +def test_actual_activity_context_replaces_element_heuristic(): + row = AvasthaCalculator().calculate_all_avasthas( + 'Sun', 'Aries', 0, 1, + activity_context=dict(moon_longitude=0, asc_longitude=0, hours_since_sunrise=3.2), + ) + assert row['avasthas']['Shayanadi']['state'] == 'Nidra' + assert row['avasthas']['Shayanadi']['method_status'] == 'pvr_15_4_4_running_ghati' + assert row['avasthas']['Shayanadi']['activity_intensity_status'] == 'requires_name_first_sound_index' + + +@pytest.mark.parametrize('elapsed', [-1, float('nan'), float('inf'), True]) +def test_invalid_sunrise_elapsed_is_not_silently_defaulted(elapsed): + with pytest.raises(ValueError): + calculate_pvr_shayanadi('Sun', 0, 0, 0, elapsed)