fix(avastha): keep simultaneous conditions and use the PVR activity formula

Upstream e628118d, 7d545039, f75a6e77, and ac565836. Dignity follows the sign, Deeptadi keeps every condition that applies, and Shayanadi hours come from the previous Hindu sunrise. BUG-1389.

Co-Authored-By: Grok (agent loop)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jesse-ux
2026-10-18 16:57:45 +08:00
co-authored by Grok (agent loop) Cursor
parent 0bd254cf18
commit 83c97a8976
9 changed files with 365 additions and 49 deletions
+15
View File
@@ -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`。
+8 -1
View File
@@ -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"]) {
+20 -1
View File
@@ -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");
}
});
+160 -46
View File
@@ -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(种子咒)")
+13 -1
View File
@@ -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}")
+3
View File
@@ -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')} | "
+64
View File
@@ -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'
+39
View File
@@ -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=[]), '<avastha-main>', '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
+43
View File
@@ -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)