merge origin/staging into narrower delivery (只碰文档;显示漏段 BUG-1375 顺延为 BUG-1392, issue #77)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LutgzyJqNhPpZy1nFsJtN3
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
"""Put the repository root on sys.path for scripts copied from upstream.
|
||||
|
||||
Those modules optionally import ``_script_root`` before their other imports.
|
||||
This package only adjusts the import path. It does not calculate charts.
|
||||
It lives in a subdirectory so it does not consume a ``scripts/`` root slot.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
root_text = str(ROOT)
|
||||
if root_text not in sys.path:
|
||||
sys.path.insert(0, root_text)
|
||||
@@ -45,6 +45,7 @@ HEAVY_COMPUTE_PATHS = frozenset(
|
||||
"/api/vedastro/range_scan",
|
||||
"/api/vedastro_gateway/run",
|
||||
"/api/thematic_report",
|
||||
"/api/synastry_full_dossier",
|
||||
"/api/qizheng",
|
||||
"/api/western",
|
||||
"/api/ephemeris_events",
|
||||
|
||||
@@ -44,6 +44,7 @@ OFFLINE_SCRATCH_NAMES = frozenset(
|
||||
"consult_full_data_cache",
|
||||
# 与 consult_full_data_cache 同走 get_or_build,每次调用先 prune_cache(name=…) 删两天前的文件,自己会清;含出生资料,保留期 2 天。
|
||||
"report_reference_cache",
|
||||
"synastry_full_dossier_cache",
|
||||
"ocr_extract",
|
||||
"three_engine_parity",
|
||||
"public_release_deny_patterns.txt",
|
||||
|
||||
+134
-14
@@ -7,7 +7,7 @@ Argala(门闩)行星干预模块 v1.1
|
||||
- 主Argala: 2/4/11宫
|
||||
- Virodhargala: 12/10/3宫分别阻挡2/4/11宫
|
||||
- 特殊Argala: 第3宫有2颗以上凶星
|
||||
- 次级Argala: 5/9宫
|
||||
- 次级:第5宫是 Argala,第9宫是它的 Virodhargala,不再把第9宫另算一条次级 Argala
|
||||
- Argala Rajayoga分类: Poornargala / Tripadargala / Ardhargala / Padargala
|
||||
|
||||
MIT复用来源:
|
||||
@@ -62,9 +62,71 @@ def classify_argala_rajayoga(argala_result: Dict) -> Dict:
|
||||
return {'type': 'None', 'level': 0, 'cn': '无明显Argala'}
|
||||
|
||||
|
||||
def _pair_status(planets, blockers):
|
||||
if not planets:
|
||||
return 'absent'
|
||||
if len(planets) == len(blockers):
|
||||
return 'strength_comparison_required'
|
||||
return 'effective' if len(planets) > len(blockers) else 'obstructed'
|
||||
|
||||
|
||||
def _compare_pair(planets, blockers, sign_idx, block_idx, chart, include_nodes):
|
||||
"""Equal occupancy uses the two Narayana levels already in select_narayana_seed_sign.
|
||||
|
||||
Later strength layers are not ported. A remaining tie stays unresolved.
|
||||
"""
|
||||
status = _pair_status(planets, blockers)
|
||||
if status != 'strength_comparison_required':
|
||||
return status, {'status': 'not_required', 'basis': 'occupant_count'}
|
||||
metadata = dict(
|
||||
profile='rath_argala_role_contacts_primary_lords_v1',
|
||||
source='Rath Jaimini Upadesa Sutra course, source p363',
|
||||
claim_boundary='First two levels only; traditional primary lords; later ties unresolved.',
|
||||
)
|
||||
missing = sorted((NATURAL_BENEFICS | NATURAL_MALEFICS) - set(chart))
|
||||
if include_nodes:
|
||||
missing += sorted(NODES - set(chart))
|
||||
if missing:
|
||||
return status, dict(metadata, status='blocked', reason='missing_classical_planet_signs', missing=missing)
|
||||
try:
|
||||
from scripts.narayana_dasha import select_narayana_seed_sign
|
||||
except ModuleNotFoundError:
|
||||
from narayana_dasha import select_narayana_seed_sign
|
||||
classical = NATURAL_BENEFICS | NATURAL_MALEFICS | (NODES if include_nodes else set())
|
||||
decision = select_narayana_seed_sign(
|
||||
sign_idx,
|
||||
{p: (s % 12) * 30.0 for p, s in chart.items() if p in classical},
|
||||
comparison_sign_idx=block_idx,
|
||||
)
|
||||
decision['implementation_profile'] = decision.pop('profile')
|
||||
decision.update(metadata)
|
||||
if decision['status'] == 'selected':
|
||||
status = 'effective' if decision['selected_sign_idx'] == sign_idx else 'obstructed'
|
||||
else:
|
||||
decision['reason'] = 'later_argala_sign_strength_rules_required'
|
||||
return status, decision
|
||||
|
||||
|
||||
_VERDICT_LABEL = {
|
||||
'strength_comparison_required': '待定',
|
||||
'special_argala_adjudication_required': '待定',
|
||||
'mixed': '吉凶交错',
|
||||
'supported': '支持',
|
||||
'obstructed': '受阻',
|
||||
'neutral': '中性',
|
||||
}
|
||||
|
||||
|
||||
def calc_argala_for_reference(ref_sign_idx: int, planet_sign_indices: Dict[str, int], include_nodes: bool = False) -> Dict:
|
||||
"""计算单一参考点的完整Argala。"""
|
||||
# Ketu in the reference sign reverses counting whether or not nodes count as occupants.
|
||||
direction = -1 if planet_sign_indices.get('Ketu') == ref_sign_idx else 1
|
||||
|
||||
def reference_house(offset):
|
||||
return (ref_sign_idx + direction * (offset - 1)) % 12
|
||||
|
||||
result = {
|
||||
'source_reference_packets': ['science_of_light_v1_argala_reference_points'],
|
||||
'ref_sign': SIGNS[ref_sign_idx],
|
||||
'ref_sign_idx': ref_sign_idx,
|
||||
'primary': {},
|
||||
@@ -73,15 +135,20 @@ def calc_argala_for_reference(ref_sign_idx: int, planet_sign_indices: Dict[str,
|
||||
'argala_count': 0,
|
||||
'virodhargala_count': 0,
|
||||
'net_result': 'neutral',
|
||||
'counting_direction': 'antizodiacal' if direction == -1 else 'zodiacal',
|
||||
'rule_source': 'PVR Integrated Approach §§10.5–10.7, source pages 117–118',
|
||||
}
|
||||
|
||||
for h in sorted(PRIMARY_ARGALA):
|
||||
argala_sign = _house_from(ref_sign_idx, h)
|
||||
argala_sign = reference_house(h)
|
||||
planets = _planets_in_sign(argala_sign, planet_sign_indices, include_nodes)
|
||||
block_h = VIRODHARGALA_MAP[h]
|
||||
block_sign = _house_from(ref_sign_idx, block_h)
|
||||
block_sign = reference_house(block_h)
|
||||
blockers = _planets_in_sign(block_sign, planet_sign_indices, include_nodes)
|
||||
effective = len(planets) > len(blockers)
|
||||
status, comparison = _compare_pair(
|
||||
planets, blockers, argala_sign, block_sign, planet_sign_indices, include_nodes,
|
||||
)
|
||||
effective = None if status == 'strength_comparison_required' else status == 'effective'
|
||||
result['primary'][f'H{h}'] = {
|
||||
'house_from_reference': h,
|
||||
'sign': SIGNS[argala_sign],
|
||||
@@ -91,14 +158,16 @@ def calc_argala_for_reference(ref_sign_idx: int, planet_sign_indices: Dict[str,
|
||||
'blocked_by_sign': SIGNS[block_sign],
|
||||
'blockers': blockers,
|
||||
'effective': effective,
|
||||
'effect': _argala_effect(h, planets, blockers),
|
||||
'comparison_status': status,
|
||||
'strength_comparison': comparison,
|
||||
'effect': _argala_effect(h, planets, blockers, status),
|
||||
}
|
||||
if planets:
|
||||
result['argala_count'] += 1
|
||||
if blockers:
|
||||
result['virodhargala_count'] += 1
|
||||
|
||||
h3_sign = _house_from(ref_sign_idx, 3)
|
||||
h3_sign = reference_house(3)
|
||||
h3_planets = _planets_in_sign(h3_sign, planet_sign_indices, include_nodes)
|
||||
h3_malefics = [p for p in h3_planets if p in NATURAL_MALEFICS]
|
||||
result['specific'] = {
|
||||
@@ -109,25 +178,56 @@ def calc_argala_for_reference(ref_sign_idx: int, planet_sign_indices: Dict[str,
|
||||
'malefics': h3_malefics,
|
||||
'effective': len(h3_malefics) >= 2,
|
||||
'rule': '第3宫有2颗以上天然凶星形成特殊Argala',
|
||||
'method_status': 'parameter_sensitive_two_malefic_threshold',
|
||||
'obstruction_rule': 'Once formed, special third-house Argala has no obstruction (Rath source p363). Formation threshold remains profile-dependent.',
|
||||
}
|
||||
if result['specific']['effective']:
|
||||
result['argala_count'] += 1
|
||||
|
||||
for h in sorted(SECONDARY_ARGALA):
|
||||
sign_idx = _house_from(ref_sign_idx, h)
|
||||
sign_idx = reference_house(h)
|
||||
planets = _planets_in_sign(sign_idx, planet_sign_indices, include_nodes)
|
||||
result['secondary'][f'H{h}'] = {
|
||||
'house_from_reference': h,
|
||||
'sign': SIGNS[sign_idx],
|
||||
'sign_idx': sign_idx,
|
||||
'planets': planets,
|
||||
'role': 'argala' if h == 5 else 'virodhargala',
|
||||
}
|
||||
|
||||
if result['argala_count'] > result['virodhargala_count']:
|
||||
fifth, ninth = result['secondary']['H5'], result['secondary']['H9']
|
||||
secondary_status, comparison = _compare_pair(
|
||||
fifth['planets'], ninth['planets'], fifth['sign_idx'], ninth['sign_idx'],
|
||||
planet_sign_indices, include_nodes,
|
||||
)
|
||||
fifth.update(
|
||||
blocked_by_house=9,
|
||||
blocked_by_sign=ninth['sign'],
|
||||
strength_comparison=comparison,
|
||||
blockers=ninth['planets'],
|
||||
comparison_status=secondary_status,
|
||||
effective=None if secondary_status == 'strength_comparison_required' else secondary_status == 'effective',
|
||||
)
|
||||
ninth['obstructs_house'] = 5
|
||||
|
||||
statuses = [row['comparison_status'] for row in result['primary'].values()]
|
||||
statuses.append(secondary_status)
|
||||
if 'strength_comparison_required' in statuses:
|
||||
result['net_result'] = 'strength_comparison_required'
|
||||
elif 'effective' in statuses and 'obstructed' in statuses:
|
||||
result['net_result'] = 'mixed'
|
||||
elif 'effective' in statuses:
|
||||
result['net_result'] = 'supported'
|
||||
elif result['argala_count'] < result['virodhargala_count']:
|
||||
elif 'obstructed' in statuses:
|
||||
result['net_result'] = 'obstructed'
|
||||
if result['specific']['effective']:
|
||||
result['net_result'] = 'special_argala_adjudication_required'
|
||||
result['verdict_label'] = _VERDICT_LABEL[result['net_result']]
|
||||
result['rajayoga_classification'] = classify_argala_rajayoga(result)
|
||||
result['rajayoga_classification']['method_status'] = 'legacy_occupancy_heuristic'
|
||||
result['rajayoga_classification']['claim_boundary'] = (
|
||||
'Occupancy label only; not an adjudication of obstruction or a verified Rajayoga.'
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@@ -167,24 +267,38 @@ def _summarize_argala(houses: Dict) -> Dict:
|
||||
supported = []
|
||||
obstructed = []
|
||||
strongest = []
|
||||
unresolved = []
|
||||
mixed = []
|
||||
for house_key, data in houses.items():
|
||||
if data['net_result'] == 'supported':
|
||||
supported.append(house_key)
|
||||
elif data['net_result'] == 'obstructed':
|
||||
obstructed.append(house_key)
|
||||
level = data.get('rajayoga_classification', {}).get('level', 0)
|
||||
strongest.append((house_key, level, data.get('rajayoga_classification', {}).get('type')))
|
||||
elif data['net_result'] == 'mixed':
|
||||
mixed.append(house_key)
|
||||
elif str(data['net_result']).endswith('_required'):
|
||||
unresolved.append(house_key)
|
||||
if house_key not in unresolved:
|
||||
level = sum(row.get('effective') is True for row in data['primary'].values())
|
||||
level += int(data['secondary']['H5'].get('effective') is True)
|
||||
strongest.append((house_key, level, 'effective_pair_count'))
|
||||
strongest.sort(key=lambda x: x[1], reverse=True)
|
||||
return {
|
||||
summary = {
|
||||
'supported_houses': supported,
|
||||
'obstructed_houses': obstructed,
|
||||
'top_argala_houses': [{'house': h, 'level': lvl, 'type': typ} for h, lvl, typ in strongest[:5] if lvl > 0],
|
||||
'supported_count': len(supported),
|
||||
'obstructed_count': len(obstructed),
|
||||
'unresolved_houses': unresolved,
|
||||
'mixed_houses': mixed,
|
||||
'ranking_basis': 'effective_pair_count_not_classical_rajayoga_strength',
|
||||
}
|
||||
if unresolved:
|
||||
summary['unresolved_label'] = '待定'
|
||||
return summary
|
||||
|
||||
|
||||
def _argala_effect(house: int, planets: List[str], blockers: List[str]) -> str:
|
||||
def _argala_effect(house: int, planets: List[str], blockers: List[str], status=None) -> str:
|
||||
if not planets:
|
||||
return '无行星形成此类Argala'
|
||||
base = {
|
||||
@@ -192,7 +306,13 @@ def _argala_effect(house: int, planets: List[str], blockers: List[str]) -> str:
|
||||
4: '内在稳定、住所、母亲、教育与幸福感支持',
|
||||
11: '收益、人脉、愿望实现与社会网络支持',
|
||||
}.get(house, '一般支持')
|
||||
if blockers and len(blockers) >= len(planets):
|
||||
if blockers and len(blockers) == len(planets):
|
||||
if status == 'effective':
|
||||
return f'{base},双方数量相等,所选强弱口径判为有效'
|
||||
if status == 'obstructed':
|
||||
return f'{base},双方数量相等,所选强弱口径判为受阻'
|
||||
return f'待定:{base},双方数量相等,须比较强弱后裁决'
|
||||
if blockers and len(blockers) > len(planets):
|
||||
return f'{base},但被{len(blockers)}个Virodhargala阻挡'
|
||||
if blockers:
|
||||
return f'{base},有轻度阻挡但仍可能有效'
|
||||
|
||||
+55
-21
@@ -162,6 +162,8 @@ PLANET_WEIGHTS = {
|
||||
'Sun': 5, 'Moon': 5, 'Mars': 8, 'Mercury': 5,
|
||||
'Jupiter': 10, 'Venus': 7, 'Saturn': 5
|
||||
}
|
||||
# PVR §12.7 表 28。处女座是 6。不改用 Prasna Marga 的 5。
|
||||
RASHI_PINDA_WEIGHTS = (7, 10, 8, 4, 10, 6, 7, 8, 9, 5, 11, 12)
|
||||
|
||||
# 行星列表
|
||||
SEVEN_PLANETS = ['Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn']
|
||||
@@ -431,13 +433,17 @@ def calc_prastara_av(planets: Dict, asc_sign_idx: int) -> Dict:
|
||||
|
||||
|
||||
def calc_yoga_pinda(bav_results: Dict, planets: Dict, asc_sign_idx: int) -> Dict:
|
||||
"""
|
||||
计算 Yoga Pinda / Shodhya Pinda 汇总。
|
||||
"""Yoga Pinda from the same Trikona and Ekadhipatya rows as the Sodhita table.
|
||||
|
||||
本项目沿用既有 v2.1 口径:取行星本星座的 BAV bindu,分别乘以星座权重与行星权重;
|
||||
两者相加作为 Yoga Pinda。该函数把原本嵌在 calc_ashtakavarga() 内部的结果提升为
|
||||
一等契约,便于 API、前端与测试直接引用。
|
||||
PVR §12.7 tables 28–29: twelve reduced signs times rashi weights (Virgo 6),
|
||||
plus the seven planets' weights at the signs they occupy. A full seven-row
|
||||
chart reuses ``sodhita_chain`` so the report cannot show two reductions.
|
||||
"""
|
||||
try:
|
||||
from ashtakavarga_shodhana import occupied_signs, sodhita_chain
|
||||
except ImportError:
|
||||
from scripts.ashtakavarga_shodhana import occupied_signs, sodhita_chain
|
||||
|
||||
source_sign_idx = {}
|
||||
for pname in SEVEN_PLANETS:
|
||||
if pname in planets:
|
||||
@@ -445,27 +451,51 @@ def calc_yoga_pinda(bav_results: Dict, planets: Dict, asc_sign_idx: int) -> Dict
|
||||
if sign in SIGNS:
|
||||
source_sign_idx[pname] = SIGNS.index(sign)
|
||||
source_sign_idx['Lagna'] = asc_sign_idx
|
||||
note = (
|
||||
'PVR §12.7 表 28–29:十二座削减值乘星座权重(处女座 6),'
|
||||
'再加七曜所在座的行星权重。与 Sodhita 表同一份削减。'
|
||||
'同值且一占一空时按 Raman 15(c) 清空空宫。'
|
||||
)
|
||||
if any(pname not in source_sign_idx for pname in SEVEN_PLANETS):
|
||||
return {
|
||||
'method': 'Yoga Pinda', 'version': '2.0',
|
||||
'profile': 'pvr_trikona_ekadhipatya_tables28_29',
|
||||
'planets': {}, 'summary': {}, 'all_valid': False,
|
||||
'validation_note': note + ' 七曜星座不全,不算 Graha Pinda。',
|
||||
}
|
||||
|
||||
available = {}
|
||||
for pname, row in bav_results.items():
|
||||
bindus = row.get('bindus') if isinstance(row, dict) else None
|
||||
if isinstance(bindus, list) and len(bindus) == 12:
|
||||
available[pname] = {'bindus': bindus}
|
||||
if set(SEVEN_PLANETS).issubset(available):
|
||||
chain = sodhita_chain(available, planets)
|
||||
trikona_rows = chain['trikona_bav']
|
||||
reduced_rows = chain['sodhita_bav']
|
||||
else:
|
||||
trikona_rows = {
|
||||
pname: calc_trikona_shodhana(row['bindus'])['reduced_scores']
|
||||
for pname, row in available.items()
|
||||
}
|
||||
reduced_rows = calc_ekadhipatya_shodhana(trikona_rows, occupied_signs(planets))['rows']
|
||||
|
||||
rows = {}
|
||||
all_valid = True
|
||||
for pname in SEVEN_PLANETS:
|
||||
bav_row = bav_results.get(pname, {})
|
||||
bindus = bav_row.get('bindus', [])
|
||||
reduced = reduced_rows.get(pname)
|
||||
own_sign = source_sign_idx.get(pname)
|
||||
if own_sign is None or len(bindus) != 12:
|
||||
bindus = (available.get(pname) or {}).get('bindus')
|
||||
if reduced is None or own_sign is None or bindus is None:
|
||||
all_valid = False
|
||||
continue
|
||||
|
||||
bindu_at_own = bindus[own_sign]
|
||||
sign_weight = 4
|
||||
planet_weight = PLANET_WEIGHTS.get(pname, 5)
|
||||
rashi_pinda = bindu_at_own * sign_weight
|
||||
graha_pinda = bindu_at_own * planet_weight
|
||||
rashi_pinda = sum(value * weight for value, weight in zip(reduced, RASHI_PINDA_WEIGHTS))
|
||||
graha_pinda = sum(
|
||||
reduced[source_sign_idx[body]] * PLANET_WEIGHTS[body] for body in SEVEN_PLANETS
|
||||
)
|
||||
rows[pname] = {
|
||||
'sign': SIGNS[own_sign],
|
||||
'bindu_at_own_sign': bindu_at_own,
|
||||
'rashi_weight': sign_weight,
|
||||
'planet_weight': planet_weight,
|
||||
'bindu_at_own_sign': bindus[own_sign],
|
||||
'rashi_pinda': rashi_pinda,
|
||||
'graha_pinda': graha_pinda,
|
||||
'yoga_pinda': rashi_pinda + graha_pinda,
|
||||
@@ -475,10 +505,14 @@ def calc_yoga_pinda(bav_results: Dict, planets: Dict, asc_sign_idx: int) -> Dict
|
||||
strongest = ranked[0][0] if ranked else None
|
||||
weakest = ranked[-1][0] if ranked else None
|
||||
total = sum(item['yoga_pinda'] for item in rows.values())
|
||||
|
||||
return {
|
||||
'method': 'Yoga Pinda',
|
||||
'version': '1.0',
|
||||
'version': '2.0',
|
||||
'profile': 'pvr_trikona_ekadhipatya_tables28_29',
|
||||
'rashi_weights': list(RASHI_PINDA_WEIGHTS),
|
||||
'planet_weights': dict(PLANET_WEIGHTS),
|
||||
'trikona_rows': trikona_rows,
|
||||
'sodhita_rows': reduced_rows,
|
||||
'planets': rows,
|
||||
'summary': {
|
||||
'strongest_planet': strongest,
|
||||
@@ -487,7 +521,7 @@ def calc_yoga_pinda(bav_results: Dict, planets: Dict, asc_sign_idx: int) -> Dict
|
||||
'average_yoga_pinda': round(total / len(rows), 2) if rows else 0,
|
||||
},
|
||||
'all_valid': all_valid and len(rows) == len(SEVEN_PLANETS),
|
||||
'validation_note': 'Yoga Pinda = Rashi Pinda + Graha Pinda;当前复用既有 v2.1 Shodhya Pinda 权重口径。',
|
||||
'validation_note': note,
|
||||
}
|
||||
|
||||
|
||||
@@ -569,5 +603,5 @@ def calc_ekadhipatya_shodhana(
|
||||
'occupied_signs': sorted(occupied_signs),
|
||||
'audit': audit,
|
||||
'status': 'executed',
|
||||
'claim_boundary': 'Applied after Trikona Shodhana; source profile follows PyJHora/PVR rule ordering.',
|
||||
'claim_boundary': 'Applied after Trikona Shodhana. One occupied and one vacant sign with the same value clears the vacant sign (Raman 15(c)).',
|
||||
}
|
||||
|
||||
@@ -364,7 +364,7 @@ def _planet_dignity_level(planet_name: str, sign: str) -> str:
|
||||
|
||||
|
||||
def calc_kuja_dosha(chart: Dict) -> Dict:
|
||||
"""计算完整的 Kuja Dosha(火星凶相分析)"""
|
||||
"""旧版 Kuja Dosha(五星合分)。新功能不得调用;婚恋合盘用 marriage_match。"""
|
||||
planets = chart.get("planets", {})
|
||||
|
||||
dosha_scores_high = {
|
||||
@@ -408,6 +408,7 @@ def calc_kuja_dosha(chart: Dict) -> Dict:
|
||||
|
||||
|
||||
def match_kuja_dosha(male_score: float, female_score: float) -> Dict:
|
||||
"""旧版:按性别比较火星冲克分数。新功能不得调用。"""
|
||||
diff = male_score - female_score
|
||||
if abs(diff) <= 5:
|
||||
return {"result": "good", "description": "Kuja Dosha balanced."}
|
||||
|
||||
+170
-47
@@ -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,34 @@ 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 and activity_context.get('unavailable_reason'):
|
||||
# 极昼极夜等算不出日出时,PVR 公式缺一项。不退回元素启发式冒充,标成无法计算(BUG-1390)。
|
||||
result['avasthas']['Shayanadi'] = {
|
||||
'state': None,
|
||||
'sign': sign,
|
||||
'meaning': '当天没有可用的日出(极昼或极夜),姿态无法按 PVR 公式计算。',
|
||||
'method_status': 'blocked',
|
||||
'blocked_reason': activity_context['unavailable_reason'],
|
||||
}
|
||||
elif 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 +261,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 +283,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 +330,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 +389,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 +480,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 +495,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:
|
||||
"""检查两个行星是否敌对(简化版)"""
|
||||
@@ -426,7 +549,7 @@ class AvasthaCalculator:
|
||||
|
||||
# Shayanadi Avastha
|
||||
shayanadi = avasthas["Shayanadi"]
|
||||
interpretation += f"5. 行为模式:{shayanadi['state']}({shayanadi['sign']})\n"
|
||||
interpretation += f"5. 行为模式:{shayanadi['state'] or '无法计算'}({shayanadi['sign']})\n"
|
||||
interpretation += f" {shayanadi['meaning']}\n"
|
||||
|
||||
return interpretation
|
||||
@@ -437,7 +560,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 +570,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(种子咒)")
|
||||
|
||||
|
||||
+34
-33
@@ -35,36 +35,34 @@ SIGN_NATURE = {
|
||||
# 宫位力量基于星座性质(BPHS标准,单位Virupas)
|
||||
# 索引0-11对应第1-12宫
|
||||
BHAVA_STRENGTH_FROM_SIGN_NATURE = {
|
||||
'nara': [30, 20, 10, 20, 10, 20, 30, 20, 10, 20, 10, 20],
|
||||
'chatushpada': [30, 20, 10, 20, 10, 20, 30, 20, 10, 20, 10, 20],
|
||||
'jalachara': [20, 30, 10, 30, 10, 30, 20, 30, 10, 30, 10, 30],
|
||||
'keeta': [10, 20, 30, 20, 30, 20, 10, 20, 30, 20, 30, 20],
|
||||
'vanaspati': [20, 10, 20, 10, 20, 30, 20, 10, 20, 10, 20, 30],
|
||||
'nara': [60, 50, 40, 30, 20, 10, 0, 10, 20, 30, 40, 50],
|
||||
'chatushpada': [30, 20, 10, 0, 10, 20, 30, 40, 50, 60, 50, 40],
|
||||
'jalachara': [30, 40, 50, 60, 50, 40, 30, 20, 10, 0, 10, 20],
|
||||
'keeta': [0, 10, 20, 30, 40, 50, 60, 50, 40, 30, 20, 10],
|
||||
}
|
||||
|
||||
# Sputa Drishti值表(基于角距离的连续相位力量)
|
||||
def _get_sputa_drishti(angle_diff: float) -> float:
|
||||
"""基于jxotishganit MIT算法,将精确角距离转换为Sputa Drishti值(0-60)"""
|
||||
ad = angle_diff % 360
|
||||
def _get_sputa_drishti(angle_diff: float, planet: str = 'Sun') -> float:
|
||||
"""jyotishganit 镜像角公式。火、木、土在特殊相位上另有一段。不是本仓方向性 Shadbala。"""
|
||||
ad = abs(angle_diff) % 360
|
||||
if ad > 180:
|
||||
ad = 360 - ad
|
||||
|
||||
if ad <= 1.0: return 60.0
|
||||
if ad <= 3.0: return 57.0
|
||||
if ad <= 7.0: return 51.0
|
||||
if ad <= 10.0: return 45.0
|
||||
if ad <= 15.0: return 40.5
|
||||
if ad <= 20.0: return 30.0
|
||||
if ad <= 25.0: return 22.5
|
||||
if ad <= 30.0: return 15.0
|
||||
if ad <= 40.0: return 12.0
|
||||
if ad <= 50.0: return 9.0
|
||||
if ad <= 60.0: return 6.0
|
||||
if ad <= 75.0: return 4.5
|
||||
if ad <= 90.0: return 3.0
|
||||
if ad <= 120.0: return 2.25
|
||||
if ad <= 150.0: return 1.5
|
||||
return 0.0
|
||||
if ad < 30:
|
||||
strength = 0.0
|
||||
elif ad < 60:
|
||||
strength = (ad - 30) / 2
|
||||
elif ad < 90:
|
||||
strength = ad - 45
|
||||
elif ad < 120:
|
||||
strength = 30 + (120 - ad) / 2
|
||||
elif ad < 150:
|
||||
strength = 150 - ad
|
||||
else:
|
||||
strength = 2 * (ad - 150)
|
||||
for special in {'Mars': (90, 210), 'Jupiter': (120, 240), 'Saturn': (60, 270)}.get(planet, ()):
|
||||
distance = abs(ad - special)
|
||||
if distance <= 15:
|
||||
strength = max(strength, 60 * (1 - distance / 15))
|
||||
return strength
|
||||
|
||||
|
||||
def calc_bhava_adhipathi_bala(house_signs: List[str], planet_shadbala: Dict) -> List[float]:
|
||||
@@ -109,8 +107,8 @@ def calc_bhava_dig_bala(house_signs: List[str], house_degrees: List[float]) -> L
|
||||
|
||||
# 处理双性星座
|
||||
if 'first_half' in nature_key:
|
||||
# Sagittarius前半=nara, Capricorn前半=jalachara
|
||||
real_nature = 'nara' if 'Sagittarius' in nature_key else 'jalachara'
|
||||
# Sagittarius 前半是人象,Capricorn 前半是四足,不是水象。
|
||||
real_nature = 'nara' if 'Sagittarius' in nature_key else 'chatushpada'
|
||||
elif 'second_half' in nature_key:
|
||||
real_nature = 'chatushpada' if 'Sagittarius' in nature_key else 'jalachara'
|
||||
else:
|
||||
@@ -143,7 +141,7 @@ def calc_bhava_drik_bala(house_signs: List[str], house_degrees: List[float],
|
||||
Returns:
|
||||
12个宫位的drik bala值
|
||||
"""
|
||||
BENEFICS = ['Jupiter', 'Venus', 'Mercury']
|
||||
BENEFICS = ['Jupiter', 'Venus', 'Moon']
|
||||
MALEFICS = ['Saturn', 'Mars', 'Sun']
|
||||
|
||||
asc_sign_idx = SIGNS.index(asc_sign) if asc_sign in SIGNS else 0
|
||||
@@ -175,7 +173,7 @@ def calc_bhava_drik_bala(house_signs: List[str], house_degrees: List[float],
|
||||
dist_deg = abs(diff)
|
||||
|
||||
# Sputa Drishti值
|
||||
sputa = _get_sputa_drishti(dist_deg)
|
||||
sputa = _get_sputa_drishti(dist_deg, planet_name)
|
||||
|
||||
if planet_name in BENEFICS:
|
||||
benefic_sputa += sputa
|
||||
@@ -238,8 +236,11 @@ def calc_bhava_bala(house_signs: List[str], house_degrees: List[float],
|
||||
})
|
||||
|
||||
return {
|
||||
'method': 'Bhava Bala三元力量(基于jyotishganit MIT算法)',
|
||||
'version': '1.0',
|
||||
'method': 'jyotishganit_mirrored_equal_house_v2',
|
||||
'version': '2.0',
|
||||
'score_unit': 'Virupas',
|
||||
'method_boundary': '具名实现:镜像相位、吉星固定为月亮木星金星、水星中性、上升等宫中点。不是本仓方向性 Shadbala,也不是经典定论。',
|
||||
'claim_boundary': 'Bhava Bala 只是三分量原始分数,不能单独证明某项人生结果。',
|
||||
'houses': houses,
|
||||
'strongest_house': max(houses, key=lambda h: h['total'])['house'],
|
||||
'weakest_house': min(houses, key=lambda h: h['total'])['house'],
|
||||
@@ -281,7 +282,7 @@ def bhava_bala_from_chart(planets: Dict, asc_sign: str, asc_degree: float, shadb
|
||||
house['lord'] = SIGN_LORDS.get(house['sign'])
|
||||
house['total_rupas'] = round(house['total'] / 60.0, 2)
|
||||
house.pop('level', None)
|
||||
result['method'] = 'Bhava Bala 三分量:Bhavadhipati(宫主 Shadbala)+ Bhava Dig + Bhava Drishti(jyotishganit MIT 算法,等宫)'
|
||||
result['method'] = 'jyotishganit_mirrored_equal_house_v2:宫主 Shadbala + Dig + Drishti。吉星为月亮、木星、金星,水星中性。不是经典定论。'
|
||||
result['unit'] = 'virupa'
|
||||
result['source'] = 'scripts/bhava_bala.py'
|
||||
return result
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -61,7 +61,7 @@ def build() -> dict:
|
||||
"partial",
|
||||
"basic_safe_with_boundary",
|
||||
"36-point compatibility can be exposed as one factor only; not deterministic marriage outcome.",
|
||||
["references/oracle/ashtakoot_oracle_cases.json", "scripts/synastry.py"],
|
||||
["references/oracle/ashtakoot_oracle_cases.json", "scripts/marriage_match.py"],
|
||||
),
|
||||
layer(
|
||||
"mangal_dosha",
|
||||
|
||||
@@ -68,12 +68,18 @@ def fuzzy_match_nakshatra(name):
|
||||
return None
|
||||
|
||||
def lon_to_nakshatra(moon_lon):
|
||||
"""月亮黄经(0-360)→ Nakshatra + Pada"""
|
||||
nak_span = 360 / 27
|
||||
pada_span = nak_span / 4
|
||||
idx = int(moon_lon / nak_span)
|
||||
progress = (moon_lon % nak_span) / nak_span
|
||||
pada = int((moon_lon % nak_span) / pada_span) + 1
|
||||
"""月亮黄经(0-360)→ Nakshatra + Pada。序号与进度来自同一把尺子。"""
|
||||
try:
|
||||
from nakshatra_advanced import nakshatra_index_and_progress
|
||||
except ImportError:
|
||||
from scripts.nakshatra_advanced import nakshatra_index_and_progress
|
||||
idx, progress = nakshatra_index_and_progress(moon_lon)
|
||||
if progress == 0.0:
|
||||
pada = 1
|
||||
else:
|
||||
nak_span = 360 / 27
|
||||
pada_span = nak_span / 4
|
||||
pada = int((float(moon_lon) % nak_span) / pada_span) + 1
|
||||
return NAKSHATRA_LIST[idx], progress, pada
|
||||
|
||||
def build_dasha_timeline(birth_date_str, nak_info, nak_progress):
|
||||
|
||||
+4
-8
@@ -89,13 +89,11 @@ def calculate_gulika(
|
||||
) -> dict[str, Any]:
|
||||
"""Return Gulika from local moment/location using Swiss sunrise and sunset."""
|
||||
ayanamsa_name = normalize_ayanamsa_name(ayanamsa)
|
||||
daynight = determine_daytime(moment, lat=lat, lon=lon, tz=tz)
|
||||
daynight = determine_daytime(moment, lat=lat, lon=lon, tz=tz, rising="hindu")
|
||||
is_day = bool(daynight["is_daytime"])
|
||||
period = "day" if is_day else "night"
|
||||
start_jd = daynight["sunrise_jd_ut"] if is_day else daynight["sunset_jd_ut"]
|
||||
end_jd = daynight["sunset_jd_ut"] if is_day else daynight["sunrise_jd_ut"] + 1.0
|
||||
if end_jd <= start_jd:
|
||||
end_jd += 1.0
|
||||
end_jd = daynight["sunset_jd_ut"] if is_day else daynight["next_sunrise_jd_ut"]
|
||||
start_year, start_month, start_day, _ = swe.revjul(start_jd + float(tz) / 24.0)
|
||||
period_weekday = datetime(int(start_year), int(start_month), int(start_day)).weekday()
|
||||
if method == "saturn_part_start":
|
||||
@@ -143,13 +141,11 @@ def _temporal_upagraha(
|
||||
ayanamsa: str = DEFAULT_AYANAMSA_NAME,
|
||||
) -> dict[str, Any]:
|
||||
ayanamsa_name = normalize_ayanamsa_name(ayanamsa)
|
||||
daynight = determine_daytime(moment, lat=lat, lon=lon, tz=tz)
|
||||
daynight = determine_daytime(moment, lat=lat, lon=lon, tz=tz, rising="hindu")
|
||||
is_day = bool(daynight["is_daytime"])
|
||||
period = "day" if is_day else "night"
|
||||
start_jd = daynight["sunrise_jd_ut"] if is_day else daynight["sunset_jd_ut"]
|
||||
end_jd = daynight["sunset_jd_ut"] if is_day else daynight["sunrise_jd_ut"] + 1.0
|
||||
if end_jd <= start_jd:
|
||||
end_jd += 1.0
|
||||
end_jd = daynight["sunset_jd_ut"] if is_day else daynight["next_sunrise_jd_ut"]
|
||||
start_year, start_month, start_day, _ = swe.revjul(start_jd + float(tz) / 24.0)
|
||||
weekday = datetime(int(start_year), int(start_month), int(start_day)).weekday()
|
||||
ruler_table = DAY_RULERS if is_day else NIGHT_RULERS
|
||||
|
||||
@@ -2534,7 +2534,8 @@ def _load_local_module(module_name):
|
||||
cached = _LOCAL_MODULE_CACHE.get(module_name)
|
||||
if cached:
|
||||
return cached
|
||||
module_path = os.path.join(SCRIPTS_DIR, f'{module_name}.py')
|
||||
relative = module_name.replace('.', os.sep)
|
||||
module_path = os.path.join(SCRIPTS_DIR, f'{relative}.py')
|
||||
spec = importlib.util.spec_from_file_location(f'_jyotish_local_{module_name}', module_path)
|
||||
if not spec or not spec.loader:
|
||||
raise ImportError(f'Cannot load local module: {module_name}')
|
||||
@@ -10505,6 +10506,20 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, JyotishComputeService):
|
||||
elif path == '/api/synastry':
|
||||
result = self._compute_synastry(body)
|
||||
self._json(result)
|
||||
elif path == '/api/marriage-match':
|
||||
match_api = _load_local_module('marriage_match_service.api')
|
||||
try:
|
||||
result = match_api.compute_marriage_match(body)
|
||||
except match_api.MarriageMatchInputError as exc:
|
||||
raise BadRequest(str(exc)) from exc
|
||||
self._json(result)
|
||||
elif path == '/api/synastry_full_dossier':
|
||||
match_api = _load_local_module('marriage_match_service.api')
|
||||
try:
|
||||
result = match_api.compute_synastry_full_dossier(self, body)
|
||||
except match_api.MarriageMatchInputError as exc:
|
||||
raise BadRequest(str(exc)) from exc
|
||||
self._json(result)
|
||||
elif path == '/api/dasha':
|
||||
result = self._compute_dasha_system(body)
|
||||
self._json(result)
|
||||
|
||||
+159
-41
@@ -16,7 +16,7 @@
|
||||
nakshatra-adv 高级Nakshatra分析(Tara Bala/Sub-Lord/兼容性)(v3.7新增)
|
||||
argala Argala门闩系统(v3.7新增)
|
||||
tajika Tajika/Varshaphala年运盘(v3.7新增)
|
||||
synastry 合盘分析(Ashta Koota 36分制+Mangal Dosha)(v3.7新增)
|
||||
marriage-match 婚恋合盘(两份盘快照;七项 35 分,火星冲克与婚姻宫压力分开列)
|
||||
full-reading 全自动综合解盘(出生信息→全链路计算→完整报告)(v3.7.1新增)
|
||||
celebrity 名人案例查询(15,807条数据+SQLite验证库)
|
||||
db-stats 验证数据库统计
|
||||
@@ -435,6 +435,52 @@ def _calc_pushkara_flags(planets):
|
||||
return result
|
||||
|
||||
|
||||
def _calc_lagna_vargottamamsa(report, varga_full):
|
||||
chart = report.get('modules', {}).get('chart', {}) if isinstance(report, dict) else {}
|
||||
ascendant = chart.get('ascendant', {}) if isinstance(chart, dict) else {}
|
||||
d1_sign = ascendant.get('sign') if isinstance(ascendant, dict) else None
|
||||
d9 = varga_full.get('D9_Navamsa', {}) if isinstance(varga_full, dict) else {}
|
||||
d9_ascendant = d9.get('ascendant') if isinstance(d9, dict) else None
|
||||
if not isinstance(d9_ascendant, dict) and isinstance(d9, dict):
|
||||
d9_ascendant = d9.get('Ascendant', {})
|
||||
d9_sign = d9_ascendant.get('sign') if isinstance(d9_ascendant, dict) else None
|
||||
return {
|
||||
'd1_sign': d1_sign,
|
||||
'd9_sign': d9_sign,
|
||||
'is_lagna_vargottamamsa': bool(d1_sign and d9_sign and d1_sign == d9_sign),
|
||||
'source': 'raman_horoscope_v1_bounded_pp64',
|
||||
}
|
||||
|
||||
|
||||
def _summarize_pushkara_planets(flags):
|
||||
"""Return the compact full-reading list without discarding raw flags."""
|
||||
return [
|
||||
f"{planet} ({data['sign']} {data['degree_in_sign']:.1f}°)"
|
||||
for planet, data in flags.items()
|
||||
if data.get('pushkara_navamsa') or data.get('pushkara_bhaga')
|
||||
]
|
||||
|
||||
|
||||
def _public_secondary_dasha_module(native):
|
||||
"""Expose one native rashi dasha under the upstream full-reading key.
|
||||
|
||||
The period table and profile id stay ours. The outer status is what the
|
||||
upstream synastry validator reads, and it is never a bare error object.
|
||||
"""
|
||||
if not isinstance(native, dict):
|
||||
return {
|
||||
'status': 'blocked',
|
||||
'reason': 'native_rashi_dasha_missing',
|
||||
'periods': [],
|
||||
}
|
||||
return {
|
||||
'status': 'parameter_sensitive',
|
||||
'profile_id': native.get('profile_id'),
|
||||
'periods': list(native.get('periods') or []),
|
||||
'native_result': native,
|
||||
}
|
||||
|
||||
|
||||
def _calc_sensitive_points(planets):
|
||||
result = {}
|
||||
try:
|
||||
@@ -464,7 +510,7 @@ def _calc_sensitive_points(planets):
|
||||
|
||||
|
||||
def _calc_dasha_sandhi(dasha_result, reference_date=None, orb_days=90):
|
||||
ref = datetime.strptime(reference_date, "%Y-%m-%d") if reference_date else datetime.now()
|
||||
ref = datetime.strptime(reference_date, "%Y-%m-%d") if reference_date else datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
sandhi = []
|
||||
timeline = dasha_result.get('timeline', []) if isinstance(dasha_result, dict) else []
|
||||
for md in timeline:
|
||||
@@ -486,7 +532,11 @@ def _calc_dasha_sandhi(dasha_result, reference_date=None, orb_days=90):
|
||||
for boundary_key in ['start', 'end']:
|
||||
if boundary_key not in ad:
|
||||
continue
|
||||
bdt = datetime.strptime(ad[boundary_key], "%Y-%m-%d")
|
||||
# 被出生裁掉的小运起点不是交接。用完整起点判断,第一大运自己的 start 仍是出生前的真实交接,不跳过。
|
||||
boundary_date = ad[boundary_key]
|
||||
if boundary_key == 'start' and ad.get('unclipped_start_datetime'):
|
||||
boundary_date = datetime.fromisoformat(ad['unclipped_start_datetime']).strftime('%Y-%m-%d')
|
||||
bdt = datetime.strptime(boundary_date, "%Y-%m-%d")
|
||||
delta = (bdt - ref).days
|
||||
if abs(delta) <= orb_days:
|
||||
sandhi.append({
|
||||
@@ -494,7 +544,7 @@ def _calc_dasha_sandhi(dasha_result, reference_date=None, orb_days=90):
|
||||
'mahadasha_lord': md.get('lord'),
|
||||
'lord': ad.get('lord'),
|
||||
'boundary': boundary_key,
|
||||
'date': ad.get(boundary_key),
|
||||
'date': boundary_date,
|
||||
'days_from_reference': delta,
|
||||
'within_orb': True,
|
||||
})
|
||||
@@ -7250,9 +7300,9 @@ def render_pl9_markdown(packet: dict) -> str:
|
||||
if shodhya_pinda:
|
||||
out.extend([
|
||||
'',
|
||||
'#### Shodhya / Yoga Pinda(本地补充分值)',
|
||||
'#### Shodhya / Yoga Pinda(PVR §12.7)',
|
||||
'',
|
||||
'本表透传本地 Bindu 权重计算的 Rashi/Graha/Yoga Pinda。不等同于 PL9 第 54 页的 Trikona / Ekadhipatya reduction 前后矩阵;该页逐阶段结构仍未闭环,所有行仅作 parameter_sensitive 补充数据。',
|
||||
'本表与 Sodhita 表用同一份削减:先三方净化,再单宫主净化。同值且一占一空时按 Raman 15(c) 清空空宫。星座权重按 PVR 表 28(处女座 6),七曜权重按表 29。这是具名口径,不是经典定论。',
|
||||
'',
|
||||
'| 行星 | Rashi Pinda | Graha Pinda | Yoga Pinda | 本命星座 Bindu | 状态 |',
|
||||
'|------|-------------|-------------|------------|----------------|------|',
|
||||
@@ -12023,10 +12073,16 @@ def _load_strict_evidence_collector():
|
||||
# ============================================================================
|
||||
def _nakshatra_fields(sidereal_lon):
|
||||
"""Nakshatra, pada and lord of a sidereal longitude — planets and the ascendant alike."""
|
||||
nak_span = 360.0 / 27
|
||||
from nakshatra_advanced import nakshatra_index_and_progress
|
||||
lon = sidereal_lon % 360
|
||||
pada = int((lon % nak_span) / (nak_span / 4)) + 1
|
||||
name, lord, _ = NAKSHATRA_LIST[int(lon / nak_span) % 27]
|
||||
idx, progress = nakshatra_index_and_progress(lon)
|
||||
if progress == 0.0:
|
||||
pada = 1
|
||||
else:
|
||||
nak_span = 360.0 / 27
|
||||
pada = int((lon % nak_span) / (nak_span / 4)) + 1
|
||||
idx = int(lon / nak_span) % 27
|
||||
name, lord, _ = NAKSHATRA_LIST[idx]
|
||||
return {"nakshatra": name, "nakshatra_pada": pada, "nakshatra_lord": lord}
|
||||
|
||||
|
||||
@@ -12165,8 +12221,9 @@ def _build_pratyantar_timeline(md_lord, ad_lord, ad_start, ad_end, reference_dt=
|
||||
def cmd_dasha(args):
|
||||
nak_info = None; progress = 0.5
|
||||
if args.moon_lon is not None:
|
||||
ns = 360.0 / 27; idx = int(args.moon_lon / ns); progress = (args.moon_lon % ns) / ns
|
||||
nak_info = NAKSHATRA_LIST[idx % 27]
|
||||
from nakshatra_advanced import nakshatra_index_and_progress
|
||||
idx, progress = nakshatra_index_and_progress(args.moon_lon)
|
||||
nak_info = NAKSHATRA_LIST[idx]
|
||||
elif args.nakshatra:
|
||||
nl = args.nakshatra.lower().replace(" ", "").replace("-", "")
|
||||
for n in NAKSHATRA_LIST:
|
||||
@@ -12183,8 +12240,9 @@ def cmd_dasha(args):
|
||||
moon_lon = moon.get("degree_raw")
|
||||
if moon_lon is None:
|
||||
return {"error": "无法计算Moon位置"}
|
||||
ns = 360.0 / 27; idx = int(moon_lon / ns); progress = (moon_lon % ns) / ns
|
||||
nak_info = NAKSHATRA_LIST[idx % 27]
|
||||
from nakshatra_advanced import nakshatra_index_and_progress
|
||||
idx, progress = nakshatra_index_and_progress(moon_lon)
|
||||
nak_info = NAKSHATRA_LIST[idx]
|
||||
|
||||
nak_name, start_lord, start_years = nak_info
|
||||
birthdate = args.birthdate or f"{args.year}-{args.month:02d}-{args.day:02d}"
|
||||
@@ -14499,20 +14557,17 @@ def _tajika_annual_core(args, planet_lons, asc_si, age, mode='all'):
|
||||
# ============================================================================
|
||||
# 21. 合盘分析(v3.7新增)
|
||||
# ============================================================================
|
||||
def cmd_synastry(args):
|
||||
def cmd_marriage_match(args):
|
||||
try:
|
||||
sys.path.insert(0, SCRIPT_DIR)
|
||||
from synastry import calc_synastry
|
||||
except ImportError as e:
|
||||
return {"error": f"synastry模块导入失败: {e}"}
|
||||
# 构建两人数据
|
||||
p1 = {'moon_lon': args.moon1, 'gender': args.gender1 or 'M'}
|
||||
p2 = {'moon_lon': args.moon2, 'gender': args.gender2 or 'F'}
|
||||
if args.mars1 is not None: p1['mars_lon'] = args.mars1
|
||||
if args.mars2 is not None: p2['mars_lon'] = args.mars2
|
||||
if args.asc1 is not None: p1['asc_lon'] = args.asc1
|
||||
if args.asc2 is not None: p2['asc_lon'] = args.asc2
|
||||
return _build_response_envelope('synastry', calc_synastry(p1, p2), args=args, execution_status='executed')
|
||||
from marriage_match_service.cli import run_marriage_match_cli
|
||||
except ImportError as exc:
|
||||
return {"error": f"marriage_match模块导入失败: {exc}"}
|
||||
try:
|
||||
result = run_marriage_match_cli(args)
|
||||
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
||||
return {"error": str(exc)}
|
||||
return _build_response_envelope('marriage-match', result, args=args, execution_status='executed')
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -15425,10 +15480,15 @@ def cmd_full_reading(args):
|
||||
try:
|
||||
moon_data = planets.get('Moon', {})
|
||||
moon_lon = moon_data.get('degree_raw', moon_data.get('degree', 0))
|
||||
nak_idx = int(moon_lon / (360 / 27)) % 27
|
||||
from nakshatra_advanced import nakshatra_index_and_progress
|
||||
nak_idx, nak_progress = nakshatra_index_and_progress(moon_lon)
|
||||
if nak_progress != 0.0:
|
||||
nak_idx = int(moon_lon / (360 / 27)) % 27
|
||||
pada = int((moon_lon % (360/27)) / (360/108)) + 1
|
||||
else:
|
||||
pada = 1
|
||||
nak_name = NAKSHATRA_LIST[nak_idx][0]
|
||||
nak_lord = NAKSHATRA_LIST[nak_idx][1]
|
||||
pada = int((moon_lon % (360/27)) / (360/108)) + 1
|
||||
|
||||
birthdate = f"{args.year}-{args.month:02d}-{args.day:02d}"
|
||||
today_str = getattr(args, 'transit_date', None) or getattr(args, 'today', None) or datetime.now().strftime('%Y-%m-%d')
|
||||
@@ -15551,7 +15611,9 @@ def cmd_full_reading(args):
|
||||
report['errors'].append(f"yoga-context-injection: {yoga_ctx_e}")
|
||||
|
||||
report['modules']['vargottama'] = _calc_vargottama(planets, varga_result)
|
||||
report['modules']['lagna_vargottamamsa'] = _calc_lagna_vargottamamsa(report, varga_result)
|
||||
report['modules']['pushkara'] = _calc_pushkara_flags(planets)
|
||||
report['modules']['pushkara_planets'] = _summarize_pushkara_planets(report['modules']['pushkara'])
|
||||
report['modules']['sensitive_points'] = _calc_sensitive_points(planets)
|
||||
except Exception as e:
|
||||
report['errors'].append(f"varga-full: {e}")
|
||||
@@ -15993,6 +16055,13 @@ def cmd_full_reading(args):
|
||||
'reference_date': reference_date,
|
||||
'reason': f'{module_name}_producer_failed:{e}',
|
||||
}
|
||||
# Upstream western modules import ``scripts.*``. Running this file as a
|
||||
# script only puts ``scripts/`` on the path, and the copied files treat
|
||||
# ``_script_root`` as optional. Keep the repository root available here
|
||||
# instead of adding a local helper under ``scripts/``.
|
||||
_repo_root = str(Path(__file__).resolve().parents[1])
|
||||
if _repo_root not in sys.path:
|
||||
sys.path.append(_repo_root)
|
||||
try:
|
||||
from unified_western_reading import build_unified_western_reading
|
||||
|
||||
@@ -16036,6 +16105,37 @@ def cmd_full_reading(args):
|
||||
'status': 'blocked',
|
||||
'reason': 'reference_date_missing',
|
||||
}
|
||||
|
||||
try:
|
||||
from punarphoo import detect_punarphoo
|
||||
|
||||
report['modules']['punarphoo'] = detect_punarphoo(planets)
|
||||
except Exception as e:
|
||||
report['modules']['punarphoo'] = {
|
||||
'status': 'blocked',
|
||||
'reason': f'punarphoo_producer_failed:{e}',
|
||||
}
|
||||
try:
|
||||
from rashi_dashas_native import calculate_rashi_dashas
|
||||
|
||||
secondary_dashas = calculate_rashi_dashas(
|
||||
_birth_datetime_from_args(args),
|
||||
float(asc_deg),
|
||||
planet_lons,
|
||||
)
|
||||
report['modules']['navamsha_dasha'] = _public_secondary_dasha_module(secondary_dashas.get('navamsha'))
|
||||
except Exception as e:
|
||||
report['modules']['navamsha_dasha'] = {
|
||||
'status': 'blocked',
|
||||
'reason': f'navamsha_dasha_producer_failed:{e}',
|
||||
'periods': [],
|
||||
}
|
||||
# F2b: do not publish the current Niryaana Shoola table. It matches Shoola.
|
||||
# The synastry validator marks this row not applicable, so the pair task stays open.
|
||||
report['modules']['niryaana_shoola_dasha'] = {
|
||||
'execution_status': 'blocked',
|
||||
'reason': '口径更正中,见 TASK-niryaana-shoola-pvr-20261018',
|
||||
}
|
||||
_record_stage_timing(
|
||||
stage_timings,
|
||||
'dasha_and_core_varga_stack',
|
||||
@@ -16352,6 +16452,20 @@ def cmd_full_reading(args):
|
||||
# ── Step 10.5: Avasthas 行星状态 (v4.4.0) ──
|
||||
try:
|
||||
from avastha_calculator import AvasthaCalculator
|
||||
from saham_daynight import SahamDayNightError, determine_daytime
|
||||
try:
|
||||
avastha_solar_day = determine_daytime(
|
||||
_birth_datetime_from_args(args), lat=args.lat, lon=args.lon, tz=args.tz, rising='hindu')
|
||||
except SahamDayNightError as exc:
|
||||
# 极昼极夜没有日出:只让姿态一项标无法计算,其余四类照常(BUG-1390)。
|
||||
avastha_activity_context = {'unavailable_reason': str(exc)}
|
||||
else:
|
||||
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():
|
||||
@@ -16364,7 +16478,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}")
|
||||
@@ -16459,8 +16576,13 @@ def cmd_full_reading(args):
|
||||
jaimini_data = report['modules'].get('jaimini', {})
|
||||
chara_dasha_data = jaimini_data.get('chara_dasha', {}) if isinstance(jaimini_data, dict) else {}
|
||||
moon_lon = planet_lons.get('Moon', 0)
|
||||
moon_nakshatra_index = int(moon_lon / (360 / 27)) % 27
|
||||
moon_pada = int((moon_lon % (360 / 27)) / (360 / 108)) + 1
|
||||
from nakshatra_advanced import nakshatra_index_and_progress
|
||||
moon_nakshatra_index, moon_nak_progress = nakshatra_index_and_progress(moon_lon)
|
||||
if moon_nak_progress != 0.0:
|
||||
moon_nakshatra_index = int(moon_lon / (360 / 27)) % 27
|
||||
moon_pada = int((moon_lon % (360 / 27)) / (360 / 108)) + 1
|
||||
else:
|
||||
moon_pada = 1
|
||||
tithi_number = int(((moon_lon - planet_lons.get('Sun', 0)) % 360) / 12) + 1
|
||||
birth_dt = _birth_datetime_from_args(args)
|
||||
ll_name = SIGN_LORDS[SIGNS[asc_idx]]
|
||||
@@ -18217,16 +18339,12 @@ def main():
|
||||
p.add_argument('--hour-from-sunrise', type=float, default=6.0,
|
||||
help='从日出起算的小时数(默认6h约正午)')
|
||||
|
||||
# 21. synastry (v3.7新增)
|
||||
p = sub.add_parser('synastry', help='合盘分析(Ashta Koota 36分制)')
|
||||
p.add_argument('--moon1', type=float, required=True, help='Person1月亮黄经')
|
||||
p.add_argument('--moon2', type=float, required=True, help='Person2月亮黄经')
|
||||
p.add_argument('--mars1', type=float, default=None, help='Person1火星黄经')
|
||||
p.add_argument('--mars2', type=float, default=None, help='Person2火星黄经')
|
||||
p.add_argument('--asc1', type=float, default=None, help='Person1上升黄经')
|
||||
p.add_argument('--asc2', type=float, default=None, help='Person2上升黄经')
|
||||
p.add_argument('--gender1', default='M', help='Person1性别')
|
||||
p.add_argument('--gender2', default='F', help='Person2性别')
|
||||
# 21. marriage-match(上游 bde5d615;只收盘快照)
|
||||
p = sub.add_parser('marriage-match', help='婚恋合盘(两份盘快照)')
|
||||
p.add_argument('--person-a', required=True, help='Person A 盘快照 JSON 文件')
|
||||
p.add_argument('--person-b', required=True, help='Person B 盘快照 JSON 文件')
|
||||
p.add_argument('--profile', default='practitioner_consensus_v1',
|
||||
choices=['practitioner_consensus_v1', 'raman_muhurtha_v1'])
|
||||
|
||||
# 22. full-reading (v3.7.1新增)
|
||||
p = sub.add_parser('full-reading', help='全自动综合解盘(出生信息→全链路→完整报告)')
|
||||
@@ -18371,7 +18489,7 @@ def main():
|
||||
'varga-full': cmd_varga_full, 'aspects': cmd_aspects, 'jaimini': cmd_jaimini,
|
||||
'nakshatra-adv': cmd_nakshatra_adv, 'nakshatra-dasha': cmd_nakshatra_dasha,
|
||||
'nakshatra-full': cmd_nakshatra_full, 'argala': cmd_argala, 'tajika': cmd_tajika,
|
||||
'synastry': cmd_synastry, 'solar-return': cmd_solar_return,
|
||||
'marriage-match': cmd_marriage_match, 'solar-return': cmd_solar_return,
|
||||
'narayana-dasha': cmd_narayana_dasha, 'muhurta': cmd_muhurta,
|
||||
'full-reading': cmd_full_reading, 'pl9-export': cmd_pl9_export, 'prashna': cmd_prashna,
|
||||
'double-transit-pac': cmd_double_transit_pac,
|
||||
|
||||
+34
-33
@@ -11,7 +11,10 @@ KP (Krishnamurti Paddhati) 占星系统模块
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
from bisect import bisect_right
|
||||
from datetime import datetime, timedelta
|
||||
from fractions import Fraction
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Tuple, Optional
|
||||
|
||||
@@ -44,6 +47,29 @@ VIMSHOTTARI_YEARS = dict(zip(KP_LORDS, VIMSHOTTARI_DURATION))
|
||||
|
||||
NAKSHATRA_SPAN = 360.0 / 27.0 # 13.333... degrees
|
||||
|
||||
|
||||
def _angular_partition_starts():
|
||||
"""Exact proportional starts, rounded once at the float API boundary."""
|
||||
starts, owners = [], []
|
||||
for star in range(27):
|
||||
sub_start = Fraction(40 * star, 3)
|
||||
for offset in range(9):
|
||||
sub = (star + offset) % 9
|
||||
sub_span = Fraction(40, 3) * VIMSHOTTARI_DURATION[sub] / 120
|
||||
child_start = sub_start
|
||||
for child_offset in range(9):
|
||||
child = (sub + child_offset) % 9
|
||||
starts.append(float(child_start))
|
||||
owners.append((sub, child))
|
||||
child_start += sub_span * VIMSHOTTARI_DURATION[child] / 120
|
||||
sub_start += sub_span
|
||||
return starts, owners
|
||||
|
||||
|
||||
_KP_STARTS, _KP_OWNERS = _angular_partition_starts()
|
||||
_STAR_STARTS = [float(Fraction(40 * i, 3)) for i in range(27)]
|
||||
_PADA_STARTS = [float(Fraction(10 * i, 3)) for i in range(108)]
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
@@ -228,50 +254,25 @@ def get_kp_lords(degree: float) -> Dict:
|
||||
Returns:
|
||||
KP lords字典
|
||||
"""
|
||||
if isinstance(degree, bool) or not isinstance(degree, (int, float)) or not math.isfinite(degree):
|
||||
raise ValueError('KP longitude must be a finite number')
|
||||
deg = degree % 360
|
||||
|
||||
# 1. Sign lord
|
||||
sign_index = int(deg // 30)
|
||||
|
||||
# 2. Nakshatra
|
||||
nakshatra_index = int(deg // NAKSHATRA_SPAN) % 27
|
||||
nakshatra_deg = deg % NAKSHATRA_SPAN
|
||||
pada = int(nakshatra_deg // (NAKSHATRA_SPAN / 4)) + 1
|
||||
|
||||
# 3. Sublord & SubSubLord(KP核心算法)
|
||||
# 将Vimshottari 120年周期按比例投影到度数上
|
||||
deg_remainder = deg - 120 * int(deg / 120)
|
||||
deg_cumulative = 0.0
|
||||
|
||||
for i in range(9):
|
||||
deg_nl = NAKSHATRA_SPAN # 13.333... degrees per nakshatra
|
||||
for j in range(i, i + 9):
|
||||
j_mod = j % 9
|
||||
deg_sl = deg_nl * VIMSHOTTARI_DURATION[j_mod] / 120.0
|
||||
for k in range(j_mod, j_mod + 9):
|
||||
k_mod = k % 9
|
||||
deg_ss = deg_sl * VIMSHOTTARI_DURATION[k_mod] / 120.0
|
||||
deg_cumulative += deg_ss
|
||||
if deg_cumulative >= deg_remainder:
|
||||
return {
|
||||
'rasi_lord': SIGN_LORDS.get(SIGNS[sign_index], ''),
|
||||
'sign': SIGNS[sign_index],
|
||||
'nakshatra': NAKSHATRAS[nakshatra_index],
|
||||
'nakshatra_lord': STAR_LORDS[nakshatra_index],
|
||||
'pada': pada,
|
||||
'sub_lord': KP_LORDS[j_mod],
|
||||
'sub_sub_lord': KP_LORDS[k_mod],
|
||||
}
|
||||
|
||||
# Fallback
|
||||
# 2. Nakshatra. A shared boundary belongs to the subdivision that opens there.
|
||||
nakshatra_index = bisect_right(_STAR_STARTS, deg) - 1
|
||||
pada = (bisect_right(_PADA_STARTS, deg) - 1) % 4 + 1
|
||||
sub, child = _KP_OWNERS[bisect_right(_KP_STARTS, deg) - 1]
|
||||
return {
|
||||
'rasi_lord': SIGN_LORDS.get(SIGNS[sign_index], ''),
|
||||
'sign': SIGNS[sign_index],
|
||||
'nakshatra': NAKSHATRAS[nakshatra_index],
|
||||
'nakshatra_lord': STAR_LORDS[nakshatra_index],
|
||||
'pada': pada,
|
||||
'sub_lord': 'Unknown',
|
||||
'sub_sub_lord': 'Unknown',
|
||||
'sub_lord': KP_LORDS[sub],
|
||||
'sub_sub_lord': KP_LORDS[child],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
#!/usr/bin/env python3
|
||||
"""User-facing romantic/marriage compatibility contract.
|
||||
|
||||
This module deliberately does not reuse the historical ``calc_kuja_dosha``
|
||||
score. That function combines Mars with five pressure planets and contains a
|
||||
male/female asymmetric comparison. It remains available for legacy reports,
|
||||
but is not a valid default for a new relationship product.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from typing import Any, Dict, Iterable, Mapping, Optional
|
||||
|
||||
from ashtakoot import (
|
||||
FRIENDSHIP,
|
||||
NADI,
|
||||
SIGN_LORDS,
|
||||
SIGNS,
|
||||
calculate_ashtakoot,
|
||||
get_nakshatra,
|
||||
)
|
||||
|
||||
|
||||
PRODUCT_VERSION = "marriage_match_product_v1"
|
||||
DEFAULT_PROFILE = "practitioner_consensus_v1"
|
||||
VALID_RELATIONSHIP_TYPES = {"romantic_or_marriage", "romantic", "marriage"}
|
||||
KUJA_PLANET = "Mars"
|
||||
PAPASAMYA_PLANETS = ("Mars", "Saturn", "Sun", "Rahu", "Ketu")
|
||||
|
||||
# These profiles are deliberately qualitative. The consensus did not support
|
||||
# an invented severity percentage or a sex-specific tolerance threshold.
|
||||
RULE_PROFILES = {
|
||||
"practitioner_consensus_v1": {
|
||||
"label": "Practitioner consensus v1",
|
||||
"kuja_houses": (1, 2, 4, 7, 8, 12),
|
||||
"reference_frames": ("lagna", "moon", "venus"),
|
||||
"exact_house_sign_exceptions": {
|
||||
2: {"Gemini", "Virgo"},
|
||||
4: {"Aries", "Scorpio"},
|
||||
7: {"Capricorn", "Cancer"},
|
||||
8: {"Sagittarius", "Pisces"},
|
||||
12: {"Taurus", "Libra"},
|
||||
},
|
||||
"leo_aquarius_exception": "variant_only",
|
||||
"own_or_exalted": "mitigates",
|
||||
"jupiter_contact": "mitigates",
|
||||
"moon_contact": "mitigates",
|
||||
"papasamya_planets": PAPASAMYA_PLANETS,
|
||||
"gender_asymmetry": False,
|
||||
"notes": [
|
||||
"Kuja is Mars-only; Papasamya is a separately reported five-planet pressure layer.",
|
||||
"Lagna, Moon and Venus are shown separately; they are not converted into an opaque weighted score.",
|
||||
"Own/exalted Mars and Jupiter or Moon contact mitigate a signal in this default profile; they do not erase the raw placement.",
|
||||
"Age, caste and sex-asymmetric comparison rules are not used for a user-facing conclusion.",
|
||||
],
|
||||
},
|
||||
"raman_muhurtha_v1": {
|
||||
"label": "B. V. Raman Muhurtha variant",
|
||||
"kuja_houses": (2, 4, 7, 8, 12),
|
||||
"reference_frames": ("lagna", "moon", "venus"),
|
||||
"exact_house_sign_exceptions": {
|
||||
2: {"Gemini", "Virgo"},
|
||||
4: {"Aries", "Scorpio"},
|
||||
7: {"Capricorn", "Cancer"},
|
||||
8: {"Sagittarius", "Pisces"},
|
||||
12: {"Taurus", "Libra"},
|
||||
},
|
||||
"leo_aquarius_exception": "cancels",
|
||||
"own_or_exalted": "mitigates",
|
||||
"jupiter_contact": "mitigates",
|
||||
"moon_contact": "mitigates",
|
||||
"papasamya_planets": PAPASAMYA_PLANETS,
|
||||
"gender_asymmetry": False,
|
||||
"notes": [
|
||||
"Raman-style house set omits the first house. It is a selectable lineage, not the product default.",
|
||||
"The traditional Leo/Aquarius exception is applied only in this variant profile.",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def available_profiles() -> Dict[str, Dict[str, Any]]:
|
||||
"""Return a presentation-safe copy of available rule profiles."""
|
||||
return deepcopy(RULE_PROFILES)
|
||||
|
||||
|
||||
def _profile(profile_id: str) -> Dict[str, Any]:
|
||||
if profile_id not in RULE_PROFILES:
|
||||
raise ValueError("Unknown marriage-match rule profile: %s" % profile_id)
|
||||
return RULE_PROFILES[profile_id]
|
||||
|
||||
|
||||
def _lon(value: Any) -> Optional[float]:
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value) % 360.0
|
||||
if isinstance(value, Mapping):
|
||||
for key in ("longitude", "degree_raw", "degree", "lon"):
|
||||
if isinstance(value.get(key), (int, float)):
|
||||
return float(value[key]) % 360.0
|
||||
return None
|
||||
|
||||
|
||||
def _sign_index(value: Any) -> Optional[int]:
|
||||
if isinstance(value, str) and value in SIGNS:
|
||||
return SIGNS.index(value)
|
||||
longitude = _lon(value)
|
||||
return int(longitude // 30) if longitude is not None else None
|
||||
|
||||
|
||||
def _planet_data(chart: Mapping[str, Any], planet: str) -> Mapping[str, Any]:
|
||||
planets = chart.get("planets", {}) if isinstance(chart, Mapping) else {}
|
||||
value = planets.get(planet, {}) if isinstance(planets, Mapping) else {}
|
||||
return value if isinstance(value, Mapping) else {}
|
||||
|
||||
|
||||
def _planet_lon(chart: Mapping[str, Any], planet: str) -> Optional[float]:
|
||||
value = _planet_data(chart, planet)
|
||||
longitude = _lon(value)
|
||||
return longitude if longitude is not None else _lon(chart.get(planet + "_lon"))
|
||||
|
||||
|
||||
def _reference_lon(chart: Mapping[str, Any], reference: str) -> Optional[float]:
|
||||
if reference == "lagna":
|
||||
for value in (chart.get("ascendant"), chart.get("lagna"), chart.get("asc_lon")):
|
||||
longitude = _lon(value)
|
||||
if longitude is not None:
|
||||
return longitude
|
||||
return None
|
||||
return _planet_lon(chart, "Moon" if reference == "moon" else "Venus")
|
||||
|
||||
|
||||
def _reference_sign(chart: Mapping[str, Any], reference: str) -> Optional[int]:
|
||||
value = _reference_lon(chart, reference)
|
||||
if value is not None:
|
||||
return int(value // 30)
|
||||
if reference == "lagna":
|
||||
# Aries is index 0, which is a valid reference sign rather than a
|
||||
# missing value. Do not use boolean fallback here.
|
||||
ascendant_sign = _sign_index(chart.get("ascendant"))
|
||||
return ascendant_sign if ascendant_sign is not None else _sign_index(chart.get("lagna"))
|
||||
return _sign_index(_planet_data(chart, "Moon" if reference == "moon" else "Venus"))
|
||||
|
||||
|
||||
def _planet_sign(chart: Mapping[str, Any], planet: str) -> Optional[int]:
|
||||
lon = _planet_lon(chart, planet)
|
||||
return int(lon // 30) if lon is not None else _sign_index(_planet_data(chart, planet))
|
||||
|
||||
|
||||
def _house_from(reference_sign: Optional[int], planet_sign: Optional[int]) -> Optional[int]:
|
||||
if reference_sign is None or planet_sign is None:
|
||||
return None
|
||||
return ((planet_sign - reference_sign) % 12) + 1
|
||||
|
||||
|
||||
def _same_sign_contact(chart: Mapping[str, Any], planet_a: str, planet_b: str) -> bool:
|
||||
a, b = _planet_sign(chart, planet_a), _planet_sign(chart, planet_b)
|
||||
return a is not None and a == b
|
||||
|
||||
|
||||
def _mars_dignity(chart: Mapping[str, Any]) -> str:
|
||||
sign_idx = _planet_sign(chart, "Mars")
|
||||
if sign_idx is None:
|
||||
return "unknown"
|
||||
sign = SIGNS[sign_idx]
|
||||
if sign == "Capricorn":
|
||||
return "exalted"
|
||||
if sign in {"Aries", "Scorpio"}:
|
||||
return "own"
|
||||
if sign == "Cancer":
|
||||
return "debilitated"
|
||||
return "other"
|
||||
|
||||
|
||||
def _frame_kuja(chart: Mapping[str, Any], reference: str, profile: Mapping[str, Any]) -> Dict[str, Any]:
|
||||
mars_sign_idx = _planet_sign(chart, "Mars")
|
||||
ref_sign_idx = _reference_sign(chart, reference)
|
||||
house = _house_from(ref_sign_idx, mars_sign_idx)
|
||||
frame = {
|
||||
"reference": reference,
|
||||
"reference_sign": SIGNS[ref_sign_idx] if ref_sign_idx is not None else None,
|
||||
"mars_sign": SIGNS[mars_sign_idx] if mars_sign_idx is not None else None,
|
||||
"mars_house": house,
|
||||
"raw_signal": house in set(profile["kuja_houses"]) if house is not None else None,
|
||||
"status": "not_available" if house is None else "not_triggered",
|
||||
"mitigations": [],
|
||||
}
|
||||
if house is None or not frame["raw_signal"]:
|
||||
return frame
|
||||
|
||||
mars_sign = frame["mars_sign"]
|
||||
exact_exceptions = profile["exact_house_sign_exceptions"]
|
||||
if mars_sign in exact_exceptions.get(house, set()):
|
||||
frame["status"] = "cancelled_by_profile_exception"
|
||||
frame["mitigations"].append("house_sign_exception")
|
||||
return frame
|
||||
if mars_sign in {"Leo", "Aquarius"}:
|
||||
mode = profile["leo_aquarius_exception"]
|
||||
if mode == "cancels":
|
||||
frame["status"] = "cancelled_by_profile_exception"
|
||||
frame["mitigations"].append("leo_aquarius_profile_exception")
|
||||
return frame
|
||||
if mode == "variant_only":
|
||||
frame["mitigations"].append("leo_aquarius_exception_disputed")
|
||||
|
||||
frame["status"] = "active"
|
||||
dignity = _mars_dignity(chart)
|
||||
if dignity in {"own", "exalted"}:
|
||||
frame["mitigations"].append("mars_%s" % dignity)
|
||||
if _same_sign_contact(chart, "Mars", "Jupiter"):
|
||||
frame["mitigations"].append("jupiter_conjunction")
|
||||
if _same_sign_contact(chart, "Mars", "Moon"):
|
||||
frame["mitigations"].append("moon_conjunction")
|
||||
return frame
|
||||
|
||||
|
||||
def analyze_kuja(chart: Mapping[str, Any], profile_id: str = DEFAULT_PROFILE) -> Dict[str, Any]:
|
||||
"""Analyze Mars-only Kuja / Mangal Dosha across visible reference frames."""
|
||||
profile = _profile(profile_id)
|
||||
if _planet_sign(chart, "Mars") is None:
|
||||
return {
|
||||
"system": "Kuja / Mangal Dosha",
|
||||
"scope": "Mars only",
|
||||
"profile_id": profile_id,
|
||||
"status": "not_available",
|
||||
"reason": "Mars position is required.",
|
||||
"frames": [],
|
||||
}
|
||||
frames = [_frame_kuja(chart, ref, profile) for ref in profile["reference_frames"]]
|
||||
active = [frame for frame in frames if frame["status"] == "active"]
|
||||
cancelled = [frame for frame in frames if frame["status"] == "cancelled_by_profile_exception"]
|
||||
severity = {0: "none", 1: "mild", 2: "material", 3: "strong"}[len(active)]
|
||||
return {
|
||||
"system": "Kuja / Mangal Dosha",
|
||||
"scope": "Mars only",
|
||||
"profile_id": profile_id,
|
||||
"status": "executed",
|
||||
"active_reference_count": len(active),
|
||||
"cancelled_reference_count": len(cancelled),
|
||||
"qualitative_level": severity,
|
||||
"frames": frames,
|
||||
"boundary": "Reference count is a presentation label, not a probability or a compatibility score.",
|
||||
}
|
||||
|
||||
|
||||
def analyze_papasamya(chart: Mapping[str, Any], profile_id: str = DEFAULT_PROFILE) -> Dict[str, Any]:
|
||||
"""Report five-planet pressure separately from Mars-only Kuja."""
|
||||
profile = _profile(profile_id)
|
||||
rows = []
|
||||
for planet in profile["papasamya_planets"]:
|
||||
planet_sign = _planet_sign(chart, planet)
|
||||
if planet_sign is None:
|
||||
rows.append({"planet": planet, "status": "not_available", "frames": []})
|
||||
continue
|
||||
frames = []
|
||||
for reference in profile["reference_frames"]:
|
||||
house = _house_from(_reference_sign(chart, reference), planet_sign)
|
||||
frames.append({
|
||||
"reference": reference,
|
||||
"house": house,
|
||||
"in_pressure_house": house in set(profile["kuja_houses"]) if house is not None else None,
|
||||
})
|
||||
rows.append({"planet": planet, "status": "executed", "frames": frames})
|
||||
active = [
|
||||
{"planet": row["planet"], "reference": frame["reference"], "house": frame["house"]}
|
||||
for row in rows for frame in row.get("frames", []) if frame.get("in_pressure_house")
|
||||
]
|
||||
return {
|
||||
"system": "Papasamya pressure",
|
||||
"scope": "Mars, Saturn, Sun, Rahu and Ketu; separate from Kuja.",
|
||||
"profile_id": profile_id,
|
||||
"status": "executed" if any(row["status"] == "executed" for row in rows) else "not_available",
|
||||
"planet_rows": rows,
|
||||
"active_placements": active,
|
||||
"boundary": "This is a descriptive pressure layer. It is not renamed as Mangal Dosha and is not reduced to a sex-weighted score.",
|
||||
}
|
||||
|
||||
|
||||
def compare_kuja(person_a: Mapping[str, Any], person_b: Mapping[str, Any], profile_id: str = DEFAULT_PROFILE) -> Dict[str, Any]:
|
||||
"""Compare two Kuja results without sex-specific tolerance or scoring."""
|
||||
a, b = analyze_kuja(person_a, profile_id), analyze_kuja(person_b, profile_id)
|
||||
if a["status"] != "executed" or b["status"] != "executed":
|
||||
outcome = "incomplete"
|
||||
else:
|
||||
difference = abs(a["active_reference_count"] - b["active_reference_count"])
|
||||
outcome = "not_triggered" if not a["active_reference_count"] and not b["active_reference_count"] else (
|
||||
"broadly_balanced" if difference <= 1 else "asymmetric"
|
||||
)
|
||||
return {
|
||||
"comparison_method": "neutral_reference_count",
|
||||
"gender_asymmetry_used": False,
|
||||
"outcome": outcome,
|
||||
"person_a": a,
|
||||
"person_b": b,
|
||||
"boundary": "Both-active is a traditional balancing consideration, not an automatic approval or cancellation.",
|
||||
}
|
||||
|
||||
|
||||
def _moon_lon(chart: Mapping[str, Any]) -> Optional[float]:
|
||||
longitude = _planet_lon(chart, "Moon")
|
||||
return longitude if longitude is not None else _lon(chart.get("moon_lon"))
|
||||
|
||||
|
||||
def _are_natural_friends(lord_a: str, lord_b: str) -> bool:
|
||||
if lord_a == lord_b:
|
||||
return True
|
||||
return lord_b in FRIENDSHIP.get(lord_a, {}).get("friend", []) and lord_a in FRIENDSHIP.get(lord_b, {}).get("friend", [])
|
||||
|
||||
|
||||
def _ashtakoota_with_exceptions(
|
||||
person_a: Mapping[str, Any],
|
||||
person_b: Mapping[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
moon_a, moon_b = _moon_lon(person_a), _moon_lon(person_b)
|
||||
if moon_a is None or moon_b is None:
|
||||
return {"status": "not_available", "reason": "Both Moon longitudes are required for Ashtakoota."}
|
||||
forward = calculate_ashtakoot(moon_a, moon_b)
|
||||
reverse = calculate_ashtakoot(moon_b, moon_a)
|
||||
# The historical Varna point encodes caste hierarchy. It cannot truthfully
|
||||
# be relabelled as a modern value judgement, so the international product
|
||||
# excludes it rather than deriving a replacement from lunar signs.
|
||||
score_names = ("Vashya", "Tara", "Yoni", "GrahaMaitri", "Gana", "Bhakoot", "Nadi")
|
||||
symmetric_scores = {
|
||||
name: round(
|
||||
(float(forward["scores"][name]) + float(reverse["scores"][name])) / 2.0,
|
||||
2,
|
||||
)
|
||||
for name in score_names
|
||||
}
|
||||
international_core = {
|
||||
"method": "International Ashtakoota core v1 (both input orders averaged; classical Varna excluded)",
|
||||
"scores": symmetric_scores,
|
||||
"total_score": round(sum(symmetric_scores.values()), 2),
|
||||
"max_score": 35.0,
|
||||
"match_percentage": round(sum(symmetric_scores.values()) / 35.0 * 100, 1),
|
||||
"person_a_details": {
|
||||
"moon_sign": SIGNS[int(moon_a // 30)],
|
||||
"nakshatra": get_nakshatra(moon_a)["name"],
|
||||
"nakshatra_pada": get_nakshatra(moon_a)["pada"],
|
||||
},
|
||||
"person_b_details": {
|
||||
"moon_sign": SIGNS[int(moon_b // 30)],
|
||||
"nakshatra": get_nakshatra(moon_b)["name"],
|
||||
"nakshatra_pada": get_nakshatra(moon_b)["pada"],
|
||||
},
|
||||
"directionality": {
|
||||
"status": "symmetric",
|
||||
"rule": "all included Kutas are averaged across person_a -> person_b and person_b -> person_a",
|
||||
},
|
||||
"boundary": "This is a mutual-reference score, not a caste hierarchy or an automatic relationship verdict.",
|
||||
}
|
||||
sign_a, sign_b = SIGNS[int(moon_a // 30)], SIGNS[int(moon_b // 30)]
|
||||
nak_a, nak_b = get_nakshatra(moon_a), get_nakshatra(moon_b)
|
||||
bhakoot_exceptions = []
|
||||
if symmetric_scores["Bhakoot"] == 0:
|
||||
lord_a, lord_b = SIGN_LORDS[sign_a], SIGN_LORDS[sign_b]
|
||||
if lord_a == lord_b:
|
||||
bhakoot_exceptions.append("same_sign_lord")
|
||||
elif _are_natural_friends(lord_a, lord_b):
|
||||
bhakoot_exceptions.append("mutual_natural_friend_sign_lords")
|
||||
nadi_exceptions = []
|
||||
if symmetric_scores["Nadi"] == 0:
|
||||
if nak_a["index"] == nak_b["index"] and nak_a["pada"] != nak_b["pada"]:
|
||||
nadi_exceptions.append("same_nakshatra_different_pada")
|
||||
if sign_a == sign_b and nak_a["index"] != nak_b["index"]:
|
||||
nadi_exceptions.append("same_rashi_different_nakshatra")
|
||||
return {
|
||||
"status": "executed",
|
||||
"international_core": international_core,
|
||||
"shared_life_dialogue": {
|
||||
"status": "not_scored",
|
||||
"storage": "no_answers_collected",
|
||||
"prompts": [
|
||||
"What does a committed relationship mean to each of you?",
|
||||
"How do you want to make decisions about money, home, and care responsibilities?",
|
||||
"What boundaries, privacy expectations, and conflict-repair habits matter most?",
|
||||
"How do you each imagine work, family, location, and personal autonomy over the next few years?",
|
||||
],
|
||||
"boundary": "These are voluntary discussion prompts, not a compatibility test or a score.",
|
||||
},
|
||||
"classical_varna": {
|
||||
"status": "excluded_from_default",
|
||||
"reason": "Historical caste hierarchy is not used in the international product or its default score.",
|
||||
},
|
||||
"person_a": {"moon_sign": sign_a, "nakshatra": nak_a},
|
||||
"person_b": {"moon_sign": sign_b, "nakshatra": nak_b},
|
||||
"exception_review": {
|
||||
"bhakoot": {
|
||||
"raw_score": symmetric_scores["Bhakoot"],
|
||||
"candidate_cancellations": bhakoot_exceptions,
|
||||
"status": "candidate_mitigation" if bhakoot_exceptions else "none",
|
||||
},
|
||||
"nadi": {
|
||||
"raw_score": symmetric_scores["Nadi"],
|
||||
"candidate_cancellations": nadi_exceptions,
|
||||
"status": "candidate_mitigation" if nadi_exceptions else "none",
|
||||
},
|
||||
"yoni": {
|
||||
"raw_score": symmetric_scores["Yoni"],
|
||||
"status": "legacy_three_band_table",
|
||||
"boundary": "The legacy 4/2/0 table remains visible. Intermediate 3/1 grades require a separately versioned source table before becoming a default score.",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _structure_status(chart: Mapping[str, Any]) -> Dict[str, Any]:
|
||||
keys = {"d1_relationship", "d9", "upapada", "darakaraka", "relationship_dasha"}
|
||||
supplied = [key for key in keys if chart.get(key) is not None]
|
||||
return {
|
||||
"status": "partial" if supplied else "not_available",
|
||||
"supplied_layers": sorted(supplied),
|
||||
"required_for_complete_reading": ["D1 relationship structure", "D9", "UL", "DK", "relationship timing / Dasha"],
|
||||
"boundary": "36-point matching and Dosha layers do not replace D1/D9/UL/DK or timing review.",
|
||||
}
|
||||
|
||||
|
||||
def build_marriage_match_dossier(
|
||||
person_a: Mapping[str, Any],
|
||||
person_b: Mapping[str, Any],
|
||||
*,
|
||||
relationship_type: str = "romantic_or_marriage",
|
||||
profile_id: str = DEFAULT_PROFILE,
|
||||
) -> Dict[str, Any]:
|
||||
"""Build the stable, user-facing love/marriage compatibility payload.
|
||||
|
||||
Inputs are chart snapshots only. The caller is responsible for calculating
|
||||
D1/D9/timing snapshots; this function never stores birth details.
|
||||
"""
|
||||
if relationship_type not in VALID_RELATIONSHIP_TYPES:
|
||||
raise ValueError("This product supports romantic/marriage matching only.")
|
||||
profile = _profile(profile_id)
|
||||
kuja = compare_kuja(person_a, person_b, profile_id)
|
||||
ashtakoota = _ashtakoota_with_exceptions(person_a, person_b)
|
||||
return {
|
||||
"product": PRODUCT_VERSION,
|
||||
"relationship_type": relationship_type,
|
||||
"rule_profile": {
|
||||
"id": profile_id,
|
||||
"label": profile["label"],
|
||||
"notes": profile["notes"],
|
||||
"gender_asymmetry_used": False,
|
||||
},
|
||||
"ashtakoota_36": ashtakoota,
|
||||
"kuja_mangal_dosha": kuja,
|
||||
"papasamya": {
|
||||
"person_a": analyze_papasamya(person_a, profile_id),
|
||||
"person_b": analyze_papasamya(person_b, profile_id),
|
||||
},
|
||||
"relationship_structure": {
|
||||
"person_a": _structure_status(person_a),
|
||||
"person_b": _structure_status(person_b),
|
||||
},
|
||||
"presentation": {
|
||||
"headline": "Relationship compatibility is presented as parallel traditional layers, not one approval score.",
|
||||
"decision_boundary": "Do not let the 36-point score, Kuja, or Papasamya alone determine a relationship conclusion.",
|
||||
"not_in_scope": ["business partnerships", "family matching", "age-based cancellation", "sex-asymmetric tolerance"],
|
||||
},
|
||||
"audit": {
|
||||
"kuja": "executed: Mars-only, Lagna/Moon/Venus frames where data is supplied",
|
||||
"papasamya": "executed separately: five-planet pressure placement review",
|
||||
"ashtakoota": ashtakoota["status"],
|
||||
"d1_d9_ul_dk_timing": "input-dependent; no missing layer is fabricated",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"""Local glue for the upstream marriage-match engine. The engine file stays at scripts/marriage_match.py."""
|
||||
@@ -0,0 +1,106 @@
|
||||
"""HTTP-facing marriage match and full synastry dossier.
|
||||
|
||||
The dossier endpoint caches on the two birth payloads plus ``GITHUB_SHA``,
|
||||
the same scratch helper the report reference cache uses. A missing or
|
||||
``unknown`` commit id skips the cache.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from marriage_match import build_marriage_match_dossier
|
||||
from marriage_match_service.full_dossier import RULES_VERSION, build_synastry_full_dossier
|
||||
|
||||
try:
|
||||
from scripts import consult_full_data_cache as _cache
|
||||
except ModuleNotFoundError: # pragma: no cover - direct scripts/ execution
|
||||
import consult_full_data_cache as _cache
|
||||
|
||||
|
||||
CACHE_NAME = "synastry_full_dossier_cache"
|
||||
_BIRTH_KEYS = ("year", "month", "day", "hour", "minute", "lat", "lon", "tz")
|
||||
|
||||
|
||||
class MarriageMatchInputError(ValueError):
|
||||
"""The caller sent a shape this product does not accept."""
|
||||
|
||||
|
||||
def compute_marriage_match(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Snapshot-only product. Birth details are not accepted here."""
|
||||
if not isinstance(payload, dict):
|
||||
raise MarriageMatchInputError("request body must be an object")
|
||||
person_a = payload.get("person_a")
|
||||
person_b = payload.get("person_b")
|
||||
if not isinstance(person_a, dict) or not isinstance(person_b, dict):
|
||||
raise MarriageMatchInputError("person_a and person_b must be chart snapshots")
|
||||
profile_id = payload.get("profile_id") or "practitioner_consensus_v1"
|
||||
if not isinstance(profile_id, str) or not profile_id.strip():
|
||||
raise MarriageMatchInputError("profile_id must be a string")
|
||||
try:
|
||||
return build_marriage_match_dossier(
|
||||
person_a,
|
||||
person_b,
|
||||
profile_id=profile_id.strip(),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise MarriageMatchInputError(str(exc)) from exc
|
||||
|
||||
|
||||
def _birth(value: Any, label: str) -> dict[str, Any]:
|
||||
if not isinstance(value, dict):
|
||||
raise MarriageMatchInputError(f"{label} must be an object")
|
||||
missing = [key for key in _BIRTH_KEYS if value.get(key) is None]
|
||||
if missing:
|
||||
raise MarriageMatchInputError(f"{label} is missing {', '.join(missing)}")
|
||||
return value
|
||||
|
||||
|
||||
def _birth_identity(body: dict[str, Any]) -> dict[str, Any]:
|
||||
node = body.get("node_mode", body.get("nodeMode", "mean"))
|
||||
return {
|
||||
"year": body.get("year"),
|
||||
"month": body.get("month"),
|
||||
"day": body.get("day"),
|
||||
"hour": body.get("hour"),
|
||||
"minute": body.get("minute"),
|
||||
"second": body.get("second", 0),
|
||||
"lat": body.get("lat"),
|
||||
"lon": body.get("lon"),
|
||||
"tz": body.get("tz"),
|
||||
"ayanamsa": body.get("ayanamsa", "lahiri"),
|
||||
"node_mode": node or "mean",
|
||||
}
|
||||
|
||||
|
||||
def _commit_sha() -> str:
|
||||
return os.environ.get("GITHUB_SHA") or ""
|
||||
|
||||
|
||||
def compute_synastry_full_dossier(handler: Any, body: dict[str, Any]) -> dict[str, Any]:
|
||||
if not isinstance(body, dict):
|
||||
raise MarriageMatchInputError("request body must be an object")
|
||||
person_a = _birth(body.get("person_a"), "person_a")
|
||||
person_b = _birth(body.get("person_b"), "person_b")
|
||||
sha = _commit_sha()
|
||||
|
||||
def build(_identity: dict[str, Any]) -> dict[str, Any]:
|
||||
return build_synastry_full_dossier(handler, person_a, person_b)
|
||||
|
||||
if sha in ("", "unknown"):
|
||||
return build({})
|
||||
cached = _cache.get_or_build(
|
||||
{
|
||||
"person_a": _birth_identity(person_a),
|
||||
"person_b": _birth_identity(person_b),
|
||||
"github_sha": sha,
|
||||
"rules_version": RULES_VERSION,
|
||||
},
|
||||
build,
|
||||
cache_name=CACHE_NAME,
|
||||
)
|
||||
packet = cached["packet"]
|
||||
if not isinstance(packet, dict):
|
||||
raise MarriageMatchInputError("cached dossier was not an object")
|
||||
return packet
|
||||
@@ -0,0 +1,20 @@
|
||||
"""CLI for snapshot marriage matching. Birth details are not an input."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from marriage_match import build_marriage_match_dossier
|
||||
|
||||
|
||||
def run_marriage_match_cli(args) -> dict:
|
||||
person_a = json.loads(Path(args.person_a).read_text(encoding="utf-8"))
|
||||
person_b = json.loads(Path(args.person_b).read_text(encoding="utf-8"))
|
||||
if not isinstance(person_a, dict) or not isinstance(person_b, dict):
|
||||
raise ValueError("person snapshots must be JSON objects")
|
||||
return build_marriage_match_dossier(
|
||||
person_a,
|
||||
person_b,
|
||||
profile_id=getattr(args, "profile", None) or "practitioner_consensus_v1",
|
||||
)
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Glue around the upstream full-synastry skill.
|
||||
|
||||
Two birth payloads become two native full-readings, then the untouched
|
||||
``build_full_synastry_dossier``. Raman is applied to the snapshots the skill
|
||||
already extracted. This module does not edit those upstream files.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from marriage_match import build_marriage_match_dossier
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
DOSSIER_SCRIPT = (
|
||||
ROOT
|
||||
/ "skills"
|
||||
/ "jyotish-full-synastry-reading"
|
||||
/ "scripts"
|
||||
/ "build_full_synastry_dossier.py"
|
||||
)
|
||||
RULES_VERSION = "bde5d615:marriage_match_product_v1"
|
||||
RAMAN_PROFILE = "raman_muhurtha_v1"
|
||||
|
||||
ReadingFn = Callable[[dict[str, Any]], dict[str, Any]]
|
||||
|
||||
|
||||
def _dossier_builder():
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"build_full_synastry_dossier",
|
||||
DOSSIER_SCRIPT,
|
||||
)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError("full synastry dossier script is missing")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module.build_full_synastry_dossier
|
||||
|
||||
|
||||
def build_synastry_full_dossier(
|
||||
handler: Any,
|
||||
person_a: dict[str, Any],
|
||||
person_b: dict[str, Any],
|
||||
*,
|
||||
read_chart: ReadingFn | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Run two full-readings in parallel, then both marriage-match profiles."""
|
||||
read = read_chart or handler._compute_full_reading_for_thematic
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
future_a = pool.submit(read, person_a)
|
||||
future_b = pool.submit(read, person_b)
|
||||
reading_a = future_a.result()
|
||||
reading_b = future_b.result()
|
||||
dossier = _dossier_builder()(reading_a, reading_b)
|
||||
snapshots = dossier.get("chart_snapshots") if isinstance(dossier, dict) else {}
|
||||
snapshots = snapshots if isinstance(snapshots, dict) else {}
|
||||
raman = build_marriage_match_dossier(
|
||||
snapshots.get("person_a") or {},
|
||||
snapshots.get("person_b") or {},
|
||||
profile_id=RAMAN_PROFILE,
|
||||
)
|
||||
return {
|
||||
"rules_version": RULES_VERSION,
|
||||
"dossier": dossier,
|
||||
"raman_marriage_match": raman,
|
||||
}
|
||||
@@ -16,6 +16,14 @@ Nakshatra深度分析系统
|
||||
from typing import Dict, List, Tuple, Optional
|
||||
import math
|
||||
from datetime import datetime, timedelta
|
||||
from bisect import bisect_right
|
||||
from fractions import Fraction
|
||||
|
||||
# Exact star openings. float(Fraction) is the grid cmd_dasha must share with
|
||||
# lon_to_nakshatra; 360/27 and modulo disagree at several of these points.
|
||||
_NAK_SPAN = Fraction(40, 3)
|
||||
_NAK_STARTS = tuple(float(_NAK_SPAN * index) for index in range(27))
|
||||
_NAK_SPAN_FLOAT = 360.0 / 27.0
|
||||
|
||||
NAK_LIST = [
|
||||
("Ashwini","Ketu",7),("Bharani","Venus",20),("Krittika","Sun",6),
|
||||
@@ -53,15 +61,42 @@ NAK_ELEMENT = {
|
||||
}
|
||||
|
||||
|
||||
def nakshatra_index_and_progress(longitude: float) -> Tuple[int, float]:
|
||||
"""星宿序号和已用比例,由同一把尺子量出。
|
||||
|
||||
经度恰好等于某星宿起点时,这一点属于刚开始的那一宿,已用比例为 0。
|
||||
其余经度保持原来的取模结果,避免改动非边界点。
|
||||
"""
|
||||
lon = float(longitude) % 360.0
|
||||
idx = bisect_right(_NAK_STARTS, lon) - 1
|
||||
if idx < 0:
|
||||
idx = 0
|
||||
elif idx > 26:
|
||||
idx = 26
|
||||
if lon == _NAK_STARTS[idx]:
|
||||
return idx, 0.0
|
||||
legacy_idx = int(lon / _NAK_SPAN_FLOAT) % 27
|
||||
legacy_progress = (lon % _NAK_SPAN_FLOAT) / _NAK_SPAN_FLOAT
|
||||
return legacy_idx, legacy_progress
|
||||
|
||||
|
||||
def find_nakshatra(longitude: float) -> Dict:
|
||||
"""从恒星黄道经度精确计算Nakshatra"""
|
||||
nak_span = 360.0 / 27 # 每个星宿13.333...°
|
||||
nak_idx = int(longitude / nak_span) % 27
|
||||
nak_start = nak_idx * nak_span
|
||||
deg_in_nak = longitude - nak_start
|
||||
pada_span = nak_span / 4 # 每个Pada 3.333...°
|
||||
pada = int(deg_in_nak / pada_span) + 1
|
||||
deg_in_pada = deg_in_nak - (pada - 1) * pada_span
|
||||
idx, _progress = nakshatra_index_and_progress(longitude)
|
||||
lon = float(longitude) % 360.0
|
||||
if lon == _NAK_STARTS[idx]:
|
||||
nak_idx = idx
|
||||
deg_in_nak = 0.0
|
||||
pada = 1
|
||||
deg_in_pada = 0.0
|
||||
else:
|
||||
nak_span = 360.0 / 27 # 每个星宿13.333...°
|
||||
nak_idx = int(longitude / nak_span) % 27
|
||||
nak_start = nak_idx * nak_span
|
||||
deg_in_nak = longitude - nak_start
|
||||
pada_span = nak_span / 4 # 每个Pada 3.333...°
|
||||
pada = int(deg_in_nak / pada_span) + 1
|
||||
deg_in_pada = deg_in_nak - (pada - 1) * pada_span
|
||||
|
||||
return {
|
||||
'nakshatra': NAK_NAMES[nak_idx],
|
||||
@@ -126,11 +161,13 @@ def calc_vimshottari_start(moon_longitude: float) -> Dict:
|
||||
nak_lord = NAK_LORDS[nak_idx]
|
||||
total_years = NAK_YEARS[nak_idx]
|
||||
|
||||
# 已用比例
|
||||
nak_span = 360.0 / 27
|
||||
nak_start = nak_idx * nak_span
|
||||
deg_in_nak = moon_longitude - nak_start
|
||||
used_ratio = deg_in_nak / nak_span
|
||||
# 已用比例与星宿序号来自同一把尺子。起点处必须是 0,不能是「几乎走完」。
|
||||
_, used_ratio = nakshatra_index_and_progress(moon_longitude)
|
||||
if float(moon_longitude) % 360.0 != _NAK_STARTS[nak_idx]:
|
||||
nak_span = 360.0 / 27
|
||||
nak_start = nak_idx * nak_span
|
||||
deg_in_nak = moon_longitude - nak_start
|
||||
used_ratio = deg_in_nak / nak_span
|
||||
remaining_ratio = 1 - used_ratio
|
||||
|
||||
# 第一个Mahadasha的剩余年数
|
||||
|
||||
+43
-96
@@ -304,13 +304,6 @@ def _calc_ashtottari_antardasha(md_lord: str, md_years: float) -> List[Dict]:
|
||||
# Vimshottari Nakshatra-level 拆解
|
||||
# ============================================================================
|
||||
|
||||
# Vimshottari Dasha 顺序(9星循环)
|
||||
try:
|
||||
from dasha_common import VIMSHOTTARI_ORDER, VIMSHOTTARI_YEARS
|
||||
except ModuleNotFoundError: # pragma: no cover - package import
|
||||
from scripts.dasha_common import VIMSHOTTARI_ORDER, VIMSHOTTARI_YEARS
|
||||
|
||||
|
||||
def calc_nakshatra_dasha_breakdown(
|
||||
moon_longitude: float,
|
||||
planet_lons: Dict[str, float],
|
||||
@@ -338,87 +331,58 @@ def calc_nakshatra_dasha_breakdown(
|
||||
moon_nak_data = find_nakshatra(moon_longitude)
|
||||
moon_nak_idx = moon_nak_data['nakshatra_idx']
|
||||
|
||||
# 计算 Vimshottari 起始
|
||||
nak = find_nakshatra(moon_longitude)
|
||||
nak_idx = nak['nakshatra_idx']
|
||||
nak_span = 360.0 / 27
|
||||
nak_start = nak_idx * nak_span
|
||||
deg_in_nak = moon_longitude - nak_start
|
||||
used_ratio = deg_in_nak / nak_span
|
||||
|
||||
# 起始守护星和剩余年数
|
||||
start_lord = NAK_LORDS[nak_idx]
|
||||
start_total = VIMSHOTTARI_YEARS.get(start_lord, 7)
|
||||
first_remaining = start_total * (1 - used_ratio)
|
||||
|
||||
# 构建大运序列
|
||||
start_idx = VIMSHOTTARI_ORDER.index(start_lord)
|
||||
sequence = []
|
||||
cumulative = 0.0
|
||||
|
||||
# 首个(剩余)大运
|
||||
sequence.append({
|
||||
'lord': start_lord,
|
||||
'years': round(first_remaining, 4),
|
||||
'cumulative_from': 0.0,
|
||||
'is_first': True,
|
||||
})
|
||||
cumulative += first_remaining
|
||||
|
||||
idx = (start_idx + 1) % 9
|
||||
for _ in range(15): # 足够覆盖到120岁
|
||||
lord = VIMSHOTTARI_ORDER[idx]
|
||||
years = VIMSHOTTARI_YEARS[lord]
|
||||
sequence.append({
|
||||
'lord': lord,
|
||||
'years': round(years, 4),
|
||||
'cumulative_from': round(cumulative, 4),
|
||||
'is_first': False,
|
||||
})
|
||||
cumulative += years
|
||||
idx = (idx + 1) % 9
|
||||
if cumulative > 120:
|
||||
break
|
||||
|
||||
# 定位当前大运
|
||||
current_md = None
|
||||
current_md_idx = -1
|
||||
for i, item in enumerate(sequence):
|
||||
if item['cumulative_from'] <= age < (item['cumulative_from'] + item['years']):
|
||||
current_md = item
|
||||
current_md_idx = i
|
||||
break
|
||||
|
||||
# 小运沿用 cmd_dasha:从完整大运真实起点排,再裁到出生之后。
|
||||
# 把第一大运的剩余年数当成完整大运会把早期小运整段重切(BUG-1380)。
|
||||
from types import SimpleNamespace
|
||||
try:
|
||||
from jyotish_engine import cmd_dasha
|
||||
except ImportError:
|
||||
from scripts.jyotish_engine import cmd_dasha
|
||||
if not math.isfinite(age) or age < 0:
|
||||
raise ValueError('age must be finite and nonnegative')
|
||||
birth = datetime.fromisoformat(birth_date_str)
|
||||
reference = birth + timedelta(days=age * 365.25)
|
||||
packet = cmd_dasha(SimpleNamespace(
|
||||
moon_lon=moon_longitude, nakshatra=None, pada=None,
|
||||
birthdate=birth.date().isoformat(), year=birth.year, month=birth.month,
|
||||
day=birth.day, hour=birth.hour, minute=birth.minute, second=birth.second,
|
||||
lat=None, lon=None, tz=0, today=reference.date().isoformat(),
|
||||
dasha_year_days=365.25))
|
||||
current_md = next((row for row in packet['timeline']
|
||||
if datetime.fromisoformat(row['start_datetime']) <= reference
|
||||
< datetime.fromisoformat(row['end_datetime'])), None)
|
||||
if current_md is None:
|
||||
return {'error': f'无法定位年龄 {age} 在 Vimshottari 序列中'}
|
||||
|
||||
md_lord = current_md['lord']
|
||||
md_years = current_md['years']
|
||||
md_start_age = current_md['cumulative_from']
|
||||
years_in_md = age - md_start_age
|
||||
md_start = datetime.fromisoformat(current_md['start_datetime'])
|
||||
md_end = datetime.fromisoformat(current_md['end_datetime'])
|
||||
md_years = (md_end - md_start).total_seconds() / (86400 * 365.25)
|
||||
md_start_age = (md_start - birth).total_seconds() / (86400 * 365.25)
|
||||
years_in_md = (reference - md_start).total_seconds() / (86400 * 365.25)
|
||||
|
||||
# 大运守护星的 Nakshatra
|
||||
md_lord_nak = None
|
||||
if md_lord in planet_lons:
|
||||
md_lord_nak = find_nakshatra(planet_lons[md_lord])
|
||||
|
||||
# 计算当前 Antardasha
|
||||
antardasas = _calc_vimshottari_antardasha_full(md_lord, md_years)
|
||||
|
||||
# 定位当前 Antardasha
|
||||
ad_cumulative = 0.0
|
||||
antardasas = []
|
||||
current_ad = None
|
||||
for ad in antardasas:
|
||||
ad_end = ad_cumulative + ad['years']
|
||||
if ad_cumulative <= years_in_md < ad_end:
|
||||
for ad in current_md['antardasha_timeline']:
|
||||
ad_start = datetime.fromisoformat(ad['start_datetime'])
|
||||
ad_end = datetime.fromisoformat(ad['end_datetime'])
|
||||
years = (ad_end - ad_start).total_seconds() / (86400 * 365.25)
|
||||
antardasas.append({'lord': ad['lord'], 'years': years})
|
||||
if ad_start <= reference < ad_end:
|
||||
nominal_start = datetime.fromisoformat(ad['unclipped_start_datetime'])
|
||||
span = (ad_end - nominal_start).total_seconds()
|
||||
current_ad = {
|
||||
'lord': ad['lord'],
|
||||
'years': ad['years'],
|
||||
'progress': (years_in_md - ad_cumulative) / ad['years'] if ad['years'] > 0 else 0,
|
||||
'remaining': round(ad_end - years_in_md, 4),
|
||||
'years': round(years, 4),
|
||||
'progress': (reference - nominal_start).total_seconds() / span if span else 0,
|
||||
'remaining': round((ad_end - reference).total_seconds() / (86400 * 365.25), 4),
|
||||
'start_datetime': ad['start_datetime'],
|
||||
'end_datetime': ad['end_datetime'],
|
||||
}
|
||||
break
|
||||
ad_cumulative = ad_end
|
||||
|
||||
# 当前 Antardasha 守护星的 Nakshatra
|
||||
ad_lord_nak = None
|
||||
@@ -451,8 +415,8 @@ def calc_nakshatra_dasha_breakdown(
|
||||
md_years_per_pada = md_years_per_nak / 4
|
||||
|
||||
for ad in antardasas:
|
||||
if md_years_per_nak > 0:
|
||||
ad_nak = find_nakshatra(planet_lons.get(ad['lord'], 0))
|
||||
if md_years_per_nak > 0 and ad['lord'] in planet_lons:
|
||||
ad_nak = find_nakshatra(planet_lons[ad['lord']])
|
||||
pada_windows.append({
|
||||
'period': f"AD {ad['lord']}",
|
||||
'nakshatra': ad_nak['nakshatra'],
|
||||
@@ -470,6 +434,8 @@ def calc_nakshatra_dasha_breakdown(
|
||||
|
||||
return {
|
||||
'system': 'Vimshottari Nakshatra-level Breakdown',
|
||||
'period_source': 'jyotish_engine.cmd_dasha native timeline',
|
||||
'reference_datetime': reference.isoformat(timespec='seconds'),
|
||||
'moon_nakshatra': moon_nak_data['nakshatra'],
|
||||
'moon_nakshatra_idx': moon_nak_idx,
|
||||
'moon_pada': moon_nak_data['pada'],
|
||||
@@ -490,25 +456,6 @@ def calc_nakshatra_dasha_breakdown(
|
||||
}
|
||||
|
||||
|
||||
def _calc_vimshottari_antardasha_full(md_lord: str, md_years: float) -> List[Dict]:
|
||||
"""计算完整的 Vimshottari Antardasha(9个周期)"""
|
||||
md_start_idx = VIMSHOTTARI_ORDER.index(md_lord)
|
||||
total_years = sum(VIMSHOTTARI_YEARS.values()) # 120
|
||||
|
||||
result = []
|
||||
for i in range(9):
|
||||
ad_lord = VIMSHOTTARI_ORDER[(md_start_idx + i) % 9]
|
||||
ad_years_total = VIMSHOTTARI_YEARS[ad_lord]
|
||||
ad_years = md_years * ad_years_total / total_years
|
||||
result.append({
|
||||
'lord': ad_lord,
|
||||
'years': round(ad_years, 4),
|
||||
'months': round(ad_years * 12, 1),
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Nakshatra Transit Overlay(过境星宿叠加大运)
|
||||
# ============================================================================
|
||||
|
||||
@@ -280,15 +280,21 @@ def _rasi_aspects(source_sign_idx: int, target_sign_idx: int) -> bool:
|
||||
def select_narayana_seed_sign(
|
||||
lagna_sign_idx: int,
|
||||
planet_lons: Dict[str, float],
|
||||
comparison_sign_idx: int | None = None,
|
||||
) -> Dict:
|
||||
"""Select the stronger of Lagna and seventh using the first two rule levels.
|
||||
"""Select the stronger of two signs using the first two rule levels.
|
||||
|
||||
A tie is deliberately returned as blocked: later classical strength rules are
|
||||
not yet implemented and must not be silently substituted with a preference.
|
||||
The default pair is Lagna and the seventh. Argala passes the obstructing
|
||||
sign explicitly and reuses the same two levels. A tie is deliberately
|
||||
returned as blocked: later classical strength rules are not implemented
|
||||
and must not be silently substituted with a preference.
|
||||
"""
|
||||
if not 0 <= lagna_sign_idx < 12:
|
||||
raise ValueError("lagna sign index must be in 0..11")
|
||||
candidate_indices = (lagna_sign_idx, (lagna_sign_idx + 6) % 12)
|
||||
other_sign_idx = (lagna_sign_idx + 6) % 12 if comparison_sign_idx is None else int(comparison_sign_idx)
|
||||
if not 0 <= other_sign_idx < 12 or other_sign_idx == lagna_sign_idx:
|
||||
raise ValueError("comparison sign index must be a different sign in 0..11")
|
||||
candidate_indices = (lagna_sign_idx, other_sign_idx)
|
||||
candidates = {}
|
||||
for sign_idx in candidate_indices:
|
||||
lord = SIGN_LORDS[SIGNS[sign_idx]]
|
||||
@@ -312,7 +318,7 @@ def select_narayana_seed_sign(
|
||||
}
|
||||
|
||||
lagna_candidate = candidates[lagna_sign_idx]
|
||||
seventh_sign_idx = (lagna_sign_idx + 6) % 12
|
||||
seventh_sign_idx = other_sign_idx
|
||||
seventh_candidate = candidates[seventh_sign_idx]
|
||||
lagna_score = (lagna_candidate["occupant_count"], lagna_candidate["aspect_factor_count"])
|
||||
seventh_score = (seventh_candidate["occupant_count"], seventh_candidate["aspect_factor_count"])
|
||||
|
||||
@@ -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')} | "
|
||||
|
||||
@@ -30,11 +30,11 @@ from scripts.research.sealed_holdout_rerun import (
|
||||
OFFSETS = (-30, -20, -15, -10, -8, -5, -3, 0, 3, 5, 8, 10, 15, 20, 30)
|
||||
RADII = (15, 30, 60)
|
||||
MINUTE_STEP = 1
|
||||
# 原值: docs/research/reported_offset_ephemeris_nutation_2026_10_08.{freeze.json,json}
|
||||
# 新值: docs/research/reported_offset_scoring_perf_2026_10_13.{freeze.json,json}
|
||||
# 原因: BUG-1323~1325 改了冻结身份文件,输出不变,ERR-110 另存新记录
|
||||
FREEZE = ROOT / "docs/research/reported_offset_scoring_perf_2026_10_13.freeze.json"
|
||||
REPORT = ROOT / "docs/research/reported_offset_scoring_perf_2026_10_13.json"
|
||||
# 原值: docs/research/reported_offset_scoring_perf_2026_10_13.{freeze.json,json}
|
||||
# 新值: docs/research/reported_offset_upstream_sync8_2026_10_18.{freeze.json,json}
|
||||
# 原因: sync8(BUG-1380~1389)改了 ashtakavarga / dasha_analyzer / narayana_dasha,输出不变,ERR-110 另存新记录
|
||||
FREEZE = ROOT / "docs/research/reported_offset_upstream_sync8_2026_10_18.freeze.json"
|
||||
REPORT = ROOT / "docs/research/reported_offset_upstream_sync8_2026_10_18.json"
|
||||
LEGACY_REPORT = ROOT / "docs/research/reported_offset_2026_09_20.json"
|
||||
|
||||
|
||||
|
||||
@@ -27,11 +27,11 @@ from scripts.minute_rectification_feature_facts_v4 import build_feature_fact_row
|
||||
from scripts.minute_rectification_holdout_validator import validate
|
||||
|
||||
DATASET = ROOT / "references/real_case_calibration/minute_rectification_holdout_v3.json"
|
||||
# 原值: docs/research/sealed_holdout_rerun_ephemeris_nutation_2026_10_08.{freeze.json,json}
|
||||
# 新值: docs/research/sealed_holdout_rerun_scoring_perf_2026_10_13.{freeze.json,json}
|
||||
# 原因: BUG-1323~1325 改了冻结身份文件,输出不变,ERR-110 另存新记录
|
||||
FREEZE = ROOT / "docs/research/sealed_holdout_rerun_scoring_perf_2026_10_13.freeze.json"
|
||||
REPORT = ROOT / "docs/research/sealed_holdout_rerun_scoring_perf_2026_10_13.json"
|
||||
# 原值: docs/research/sealed_holdout_rerun_scoring_perf_2026_10_13.{freeze.json,json}
|
||||
# 新值: docs/research/sealed_holdout_rerun_upstream_sync8_2026_10_18.{freeze.json,json}
|
||||
# 原因: sync8(BUG-1380~1389)改了 ashtakavarga / dasha_analyzer / narayana_dasha,输出不变,ERR-110 另存新记录
|
||||
FREEZE = ROOT / "docs/research/sealed_holdout_rerun_upstream_sync8_2026_10_18.freeze.json"
|
||||
REPORT = ROOT / "docs/research/sealed_holdout_rerun_upstream_sync8_2026_10_18.json"
|
||||
LEGACY_REPORT = ROOT / "docs/research/sealed_holdout_rerun_2026_09_20.json"
|
||||
ARCHIVE = ROOT / "docs/research/history/rectification_pre_cross_midnight_2026_09_20"
|
||||
PRODUCTION_FILES = [
|
||||
|
||||
+62
-13
@@ -12,23 +12,71 @@ class SahamDayNightError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def determine_daytime(moment: datetime, *, lat: float, lon: float, tz: float) -> dict[str, Any]:
|
||||
def determine_daytime(
|
||||
moment: datetime,
|
||||
*,
|
||||
lat: float,
|
||||
lon: float,
|
||||
tz: float,
|
||||
rising: str = "apparent",
|
||||
) -> dict[str, Any]:
|
||||
"""Day/night window.
|
||||
|
||||
``rising="apparent"`` is the historical disk-edge window. Rectification and
|
||||
Panchanga rely on that default and must not pick up Hindu rising.
|
||||
``rising="hindu"`` is disk-center, no refraction, with the real next
|
||||
sunrise. Saham day/night, Gulika, and Shayanadi pass it explicitly.
|
||||
"""
|
||||
if rising not in ("apparent", "hindu"):
|
||||
raise SahamDayNightError("unsupported sunrise convention")
|
||||
if not -90 <= float(lat) <= 90 or not -180 <= float(lon) <= 180:
|
||||
raise SahamDayNightError("invalid WGS84 latitude/longitude")
|
||||
local = moment.replace(tzinfo=None)
|
||||
jd = swe.julday(local.year, local.month, local.day, local.hour + local.minute / 60 + local.second / 3600 - float(tz))
|
||||
geopos = (float(lon), float(lat), 0.0)
|
||||
rise_status, rise = swe.rise_trans(jd - 1.0, swe.SUN, swe.CALC_RISE, geopos)
|
||||
set_status, sunset = swe.rise_trans(jd - 1.0, swe.SUN, swe.CALC_SET, geopos)
|
||||
if rise_status != 0 or set_status != 0:
|
||||
raise SahamDayNightError("sunrise_or_sunset_unavailable_for_location_date")
|
||||
sunrise_jd, sunset_jd = rise[0], sunset[0]
|
||||
# Normalize the next daily events around the queried instant.
|
||||
while sunrise_jd > jd:
|
||||
sunrise_jd -= 1.0
|
||||
while sunset_jd > jd:
|
||||
sunset_jd -= 1.0
|
||||
is_day = sunrise_jd <= jd < sunset_jd if sunrise_jd < sunset_jd else not (sunset_jd <= jd < sunrise_jd)
|
||||
if rising == "apparent":
|
||||
rise_status, rise = swe.rise_trans(jd - 1.0, swe.SUN, swe.CALC_RISE, geopos)
|
||||
set_status, sunset = swe.rise_trans(jd - 1.0, swe.SUN, swe.CALC_SET, geopos)
|
||||
if rise_status != 0 or set_status != 0:
|
||||
raise SahamDayNightError("sunrise_or_sunset_unavailable_for_location_date")
|
||||
sunrise_jd, sunset_jd = rise[0], sunset[0]
|
||||
# Normalize the next daily events around the queried instant.
|
||||
while sunrise_jd > jd:
|
||||
sunrise_jd -= 1.0
|
||||
while sunset_jd > jd:
|
||||
sunset_jd -= 1.0
|
||||
is_day = sunrise_jd <= jd < sunset_jd if sunrise_jd < sunset_jd else not (sunset_jd <= jd < sunrise_jd)
|
||||
return {
|
||||
"scope": "saham_daynight_swiss",
|
||||
"status": "computed",
|
||||
"is_daytime": is_day,
|
||||
"julian_day_ut": jd,
|
||||
"sunrise_jd_ut": sunrise_jd,
|
||||
"sunset_jd_ut": sunset_jd,
|
||||
"method": "swisseph.rise_trans",
|
||||
"boundary": "Formula-specific +30 degree exceptions must be applied by the Saham rule layer, not inferred from house placement.",
|
||||
}
|
||||
|
||||
flags = swe.FLG_SWIEPH | swe.FLG_TRUEPOS
|
||||
|
||||
def event(after, kind):
|
||||
status, times = swe.rise_trans(after, swe.SUN, kind | swe.BIT_HINDU_RISING, geopos, flags=flags)
|
||||
if status != 0:
|
||||
raise SahamDayNightError("sunrise_or_sunset_unavailable_for_location_date")
|
||||
return times[0]
|
||||
|
||||
# Consecutive rise/set events. Adding one Julian day to a sunrise is not
|
||||
# the next sunrise, and it moves the night-segment endpoint.
|
||||
midnight_jd = swe.julday(local.year, local.month, local.day, -float(tz))
|
||||
sunrise_jd = event(midnight_jd, swe.CALC_RISE)
|
||||
if sunrise_jd > jd:
|
||||
midnight_jd -= 1.0
|
||||
sunrise_jd = event(midnight_jd, swe.CALC_RISE)
|
||||
next_sunrise_jd = event(midnight_jd + 1.0, swe.CALC_RISE)
|
||||
sunset_jd = event(midnight_jd, swe.CALC_SET)
|
||||
if not sunrise_jd <= jd < next_sunrise_jd or not sunrise_jd < sunset_jd < next_sunrise_jd:
|
||||
raise SahamDayNightError("solar_day_window_unavailable")
|
||||
is_day = sunrise_jd <= jd < sunset_jd
|
||||
return {
|
||||
"scope": "saham_daynight_swiss",
|
||||
"status": "computed",
|
||||
@@ -36,6 +84,7 @@ def determine_daytime(moment: datetime, *, lat: float, lon: float, tz: float) ->
|
||||
"julian_day_ut": jd,
|
||||
"sunrise_jd_ut": sunrise_jd,
|
||||
"sunset_jd_ut": sunset_jd,
|
||||
"method": "swisseph.rise_trans",
|
||||
"next_sunrise_jd_ut": next_sunrise_jd,
|
||||
"method": "swisseph.rise_trans_hindu_rising",
|
||||
"boundary": "Formula-specific +30 degree exceptions must be applied by the Saham rule layer, not inferred from house placement.",
|
||||
}
|
||||
|
||||
@@ -1,262 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Synastry(合盘)模块 — 基于 dashaflow (MIT License) 核心算法
|
||||
|
||||
16因子兼容性分析系统:
|
||||
- Ashtakoot 8因子(36分制)
|
||||
- 附加8 Kuta(Mahendra/Stree Deergha/Vedha/Kuja Dosha/Rajju等)
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Tuple, Any
|
||||
|
||||
SIGNS = ['Aries', 'Taurus', 'Gemini', 'Cancer', 'Leo', 'Virgo',
|
||||
'Libra', 'Scorpio', 'Sagittarius', 'Capricorn', 'Aquarius', 'Pisces']
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
NAKSHATRAS = ['Ashwini', 'Bharani', 'Krittika', 'Rohini', 'Mrigashira', 'Ardra',
|
||||
'Punarvasu', 'Pushya', 'Ashlesha', 'Magha', 'Purva Phalguni', 'Uttara Phalguni',
|
||||
'Hasta', 'Chitra', 'Swati', 'Vishakha', 'Anuradha', 'Jyeshtha',
|
||||
'Mula', 'Purva Ashadha', 'Uttara Ashadha', 'Shravana', 'Dhanishta', 'Shatabhisha',
|
||||
'Purva Bhadrapada', 'Uttara Bhadrapada', 'Revati']
|
||||
|
||||
# =============================================================================
|
||||
# 数据表 (来自 dashaflow MIT License)
|
||||
# =============================================================================
|
||||
|
||||
YONI_ANIMALS = [
|
||||
"Horse", "Elephant", "Sheep", "Serpent", "Serpent", "Dog", "Cat", "Sheep", "Cat",
|
||||
"Rat", "Rat", "Cow", "Buffalo", "Tiger", "Buffalo", "Tiger", "Deer", "Deer",
|
||||
"Dog", "Monkey", "Mongoose", "Monkey", "Lion", "Horse", "Lion", "Cow", "Elephant"
|
||||
]
|
||||
|
||||
YONI_ENEMIES = {
|
||||
"Horse": "Buffalo", "Buffalo": "Horse", "Elephant": "Lion", "Lion": "Elephant",
|
||||
"Sheep": "Monkey", "Monkey": "Sheep", "Serpent": "Mongoose", "Mongoose": "Serpent",
|
||||
"Dog": "Deer", "Deer": "Dog", "Cat": "Rat", "Rat": "Cat",
|
||||
"Cow": "Tiger", "Tiger": "Cow"
|
||||
}
|
||||
|
||||
GANA = [
|
||||
"Deva", "Manushya", "Rakshasa", "Manushya", "Deva", "Manushya", "Deva", "Deva", "Rakshasa",
|
||||
"Rakshasa", "Manushya", "Manushya", "Deva", "Rakshasa", "Deva", "Rakshasa", "Deva", "Rakshasa",
|
||||
"Rakshasa", "Manushya", "Manushya", "Deva", "Rakshasa", "Rakshasa", "Manushya", "Manushya", "Deva"
|
||||
]
|
||||
|
||||
NADI = [
|
||||
"Adi", "Madhya", "Antya", "Antya", "Madhya", "Adi", "Adi", "Madhya", "Antya",
|
||||
"Antya", "Madhya", "Adi", "Adi", "Madhya", "Antya", "Antya", "Madhya", "Adi",
|
||||
"Adi", "Madhya", "Antya", "Antya", "Madhya", "Adi", "Adi", "Madhya", "Antya"
|
||||
]
|
||||
|
||||
VARNA = {
|
||||
"Cancer": 1, "Scorpio": 1, "Pisces": 1,
|
||||
"Aries": 2, "Leo": 2, "Sagittarius": 2,
|
||||
"Taurus": 3, "Virgo": 3, "Capricorn": 3,
|
||||
"Gemini": 4, "Libra": 4, "Aquarius": 4
|
||||
}
|
||||
|
||||
VASHYA_TYPE = {
|
||||
"Aries": "Chatushpada", "Taurus": "Chatushpada",
|
||||
"Leo": "Vanachara", "Sagittarius": "Chatushpada", "Capricorn": "Chatushpada",
|
||||
"Gemini": "Manava", "Virgo": "Manava", "Libra": "Manava", "Aquarius": "Manava",
|
||||
"Cancer": "Jalachara", "Pisces": "Jalachara",
|
||||
"Scorpio": "Keet",
|
||||
}
|
||||
|
||||
VEDHA_PAIRS = [(0,17),(1,16),(2,15),(3,14),(5,21),(6,20),(7,19),(8,18),(9,26),(10,25),(11,24),(12,23),(4,22)]
|
||||
|
||||
RAJJU_GROUPS = {
|
||||
"Pada":{0,8,9,17,18,26}, "Kati":{1,7,10,16,25,19},
|
||||
"Udara":{2,6,11,15,20,24}, "Kanta":{3,5,12,14,21,23}, "Sira":{4,13,22}
|
||||
}
|
||||
RAJJU_EFFECTS = {"Sira":"head—risk to husband","Kanta":"neck—risk to wife","Udara":"stomach—risk to children","Kati":"waist—poverty","Pada":"foot—wandering"}
|
||||
|
||||
NATURAL_FRIENDS = {
|
||||
'Sun': ['Moon','Mars','Jupiter'], 'Moon': ['Sun','Mercury'],
|
||||
'Mars': ['Sun','Moon','Jupiter'], 'Mercury': ['Sun','Venus'],
|
||||
'Jupiter': ['Sun','Moon','Mars'], 'Venus': ['Mercury','Saturn'],
|
||||
'Saturn': ['Mercury','Venus']
|
||||
}
|
||||
NATURAL_ENEMIES = {
|
||||
'Sun': ['Saturn','Venus'], 'Moon': [], 'Mars': ['Mercury'],
|
||||
'Mercury': ['Moon'], 'Jupiter': ['Mercury','Venus'],
|
||||
'Venus': ['Sun','Moon'], 'Saturn': ['Sun','Moon','Mars']
|
||||
}
|
||||
|
||||
EXALTATION = {'Sun':('Aries',10),'Moon':('Taurus',3),'Mars':('Capricorn',28),'Mercury':('Virgo',15),'Jupiter':('Cancer',5),'Venus':('Pisces',27),'Saturn':('Libra',20)}
|
||||
DEBILITATION = {p:(SIGNS[(SIGNS.index(s)+6)%12],d) for p,(s,d) in EXALTATION.items()}
|
||||
OWN_SIGNS = {'Sun':{'Leo'},'Moon':{'Cancer'},'Mars':{'Aries','Scorpio'},'Mercury':{'Gemini','Virgo'},'Jupiter':{'Sagittarius','Pisces'},'Venus':{'Taurus','Libra'},'Saturn':{'Capricorn','Aquarius'}}
|
||||
|
||||
_DOSHA_HOUSES = {2,4,7,8,12}
|
||||
_HIGH_SEVERITY_HOUSES = {7,8}
|
||||
_MARS_EXCEPTIONS = {2:{"Gemini","Virgo"},12:{"Taurus","Libra"},4:{"Aries","Scorpio"},7:{"Capricorn","Cancer"},8:{"Sagittarius","Pisces"}}
|
||||
_MARS_EXEMPT_SIGNS = {"Aquarius","Leo"}
|
||||
|
||||
# =============================================================================
|
||||
# Ashtakoot 8因子 (36分制)
|
||||
# =============================================================================
|
||||
|
||||
def _calc_varna(m_sign, f_sign):
|
||||
return 1.0 if VARNA[m_sign] <= VARNA[f_sign] else 0.0
|
||||
|
||||
def _calc_tara(m_nak, f_nak):
|
||||
pts = 0.0
|
||||
if (f_nak-m_nak)%9 not in (2,4,6): pts += 1.5
|
||||
if (m_nak-f_nak)%9 not in (2,4,6): pts += 1.5
|
||||
return pts
|
||||
|
||||
def _calc_yoni(m_nak, f_nak):
|
||||
my, fy = YONI_ANIMALS[m_nak], YONI_ANIMALS[f_nak]
|
||||
if my == fy: return 4.0
|
||||
if YONI_ENEMIES.get(my) == fy: return 0.0
|
||||
return 2.0
|
||||
|
||||
def _calc_graha_maitri(m_sign, f_sign):
|
||||
ml, fl = SIGN_LORDS[m_sign], SIGN_LORDS[f_sign]
|
||||
def _check(p1, p2):
|
||||
if p1==p2: return 1.0
|
||||
if p2 in NATURAL_FRIENDS.get(p1,[]): return 1.0
|
||||
if p2 in NATURAL_ENEMIES.get(p1,[]): return 0.0
|
||||
return 0.5
|
||||
total = _check(ml,fl) + _check(fl,ml)
|
||||
if total == 2.0: return 5.0
|
||||
if total == 1.5: return 4.0
|
||||
if total == 1.0: return 3.0
|
||||
if total == 0.5: return 1.0
|
||||
return 0.0
|
||||
|
||||
def _calc_gana(m_nak, f_nak):
|
||||
mg, fg = GANA[m_nak], GANA[f_nak]
|
||||
if mg == fg: return 6.0
|
||||
if mg=="Deva" and fg=="Manushya": return 6.0
|
||||
if mg=="Manushya" and fg=="Deva": return 5.0
|
||||
if "Rakshasa" in (mg,fg) and "Manushya" in (mg,fg): return 0.0
|
||||
if mg=="Rakshasa" and fg=="Deva": return 1.0
|
||||
if fg=="Rakshasa" and mg=="Deva": return 0.0
|
||||
return 0.0
|
||||
|
||||
def _calc_bhakoot(m_sign, f_sign):
|
||||
mi, fi = SIGNS.index(m_sign), SIGNS.index(f_sign)
|
||||
diff = (fi - mi) % 12 + 1
|
||||
return 7.0 if diff in (1,7,3,11,4,10) else 0.0
|
||||
|
||||
def _calc_nadi(m_nak, f_nak):
|
||||
return 8.0 if NADI[m_nak] != NADI[f_nak] else 0.0
|
||||
|
||||
def _calc_vedha(m_nak, f_nak):
|
||||
for a,b in VEDHA_PAIRS:
|
||||
if (m_nak==a and f_nak==b) or (m_nak==b and f_nak==a): return "bad"
|
||||
return "good"
|
||||
|
||||
def _calc_rajju(m_nak, f_nak):
|
||||
for group, indices in RAJJU_GROUPS.items():
|
||||
if m_nak in indices and f_nak in indices:
|
||||
return {"result":"bad","group":group,"effect":RAJJU_EFFECTS[group]}
|
||||
return {"result":"good","group":None,"effect":""}
|
||||
|
||||
def _calc_mahendra(m_nak, f_nak):
|
||||
cnt = ((m_nak-f_nak)%27)+1
|
||||
return "good" if cnt in (4,7,10,13,16,19,22,25) else "bad"
|
||||
|
||||
def _calc_stree_deergha(m_nak, f_nak):
|
||||
cnt = ((m_nak-f_nak)%27)+1
|
||||
return "good" if cnt >= 9 else "bad"
|
||||
|
||||
# =============================================================================
|
||||
# 主计算函数
|
||||
# =============================================================================
|
||||
|
||||
def calc_ashtakoot(male_moon_degree: float, female_moon_degree: float,
|
||||
male_chart: Dict = None, female_chart: Dict = None) -> Dict:
|
||||
"""
|
||||
计算16因子Ashtakoot兼容性匹配(基于dashaflow MIT算法)。
|
||||
|
||||
Args:
|
||||
male_moon_degree: 男性月亮黄道经度(0-360)
|
||||
female_moon_degree: 女性月亮黄道经度(0-360)
|
||||
male_chart: 男性星盘数据(可选,用于附加Kuta)
|
||||
female_chart: 女性星盘数据(可选)
|
||||
|
||||
Returns:
|
||||
完整的兼容性分析
|
||||
"""
|
||||
m_nak = int(male_moon_degree / (360.0/27.0))
|
||||
f_nak = int(female_moon_degree / (360.0/27.0))
|
||||
m_sign = SIGNS[int(male_moon_degree/30)%12]
|
||||
f_sign = SIGNS[int(female_moon_degree/30)%12]
|
||||
|
||||
scores = {
|
||||
"Varna": _calc_varna(m_sign, f_sign),
|
||||
"Vashya": 2.0, # Simplified
|
||||
"Tara": _calc_tara(m_nak, f_nak),
|
||||
"Yoni": _calc_yoni(m_nak, f_nak),
|
||||
"GrahaMaitri": _calc_graha_maitri(m_sign, f_sign),
|
||||
"Gana": _calc_gana(m_nak, f_nak),
|
||||
"Bhakoot": _calc_bhakoot(m_sign, f_sign),
|
||||
"Nadi": _calc_nadi(m_nak, f_nak),
|
||||
}
|
||||
total = sum(scores.values())
|
||||
|
||||
rajju = _calc_rajju(m_nak, f_nak)
|
||||
additional = {
|
||||
"Mahendra": _calc_mahendra(m_nak, f_nak),
|
||||
"StreeDeergha": _calc_stree_deergha(m_nak, f_nak),
|
||||
"Vedha": _calc_vedha(m_nak, f_nak),
|
||||
"Rajju": rajju,
|
||||
}
|
||||
|
||||
# Dosha缓解逻辑
|
||||
exceptions = []
|
||||
if scores["Nadi"]==0 and scores["Bhakoot"]>0 and rajju["result"]=="good":
|
||||
exceptions.append("Nadi Dosha mitigated by good Bhakoot and Rajju")
|
||||
if rajju["result"]=="bad" and scores["GrahaMaitri"]>=4 and scores["Bhakoot"]>0 and scores["Tara"]>=1.5 and additional["Mahendra"]=="good":
|
||||
exceptions.append("Rajju Dosha mitigated")
|
||||
|
||||
assessment = "优秀" if total>=28 else "良好" if total>=21 else "一般" if total>=18 else "不推荐"
|
||||
approved = total >= 18.0 and (scores["Nadi"]>0 or len(exceptions)>0)
|
||||
|
||||
return {
|
||||
"version": "3.8-dashaflow-mit-adapted",
|
||||
"method": "Ashtakoot 16因子兼容性分析 (dashaflow MIT)",
|
||||
"male": {"moon_sign":m_sign,"nakshatra":NAKSHATRAS[m_nak],"gana":GANA[m_nak],"nadi":NADI[m_nak],"yoni":YONI_ANIMALS[m_nak]},
|
||||
"female": {"moon_sign":f_sign,"nakshatra":NAKSHATRAS[f_nak],"gana":GANA[f_nak],"nadi":NADI[f_nak],"yoni":YONI_ANIMALS[f_nak]},
|
||||
"scores": scores,
|
||||
"total_score": total,
|
||||
"max_score": 36.0,
|
||||
"assessment": assessment,
|
||||
"is_approved": approved,
|
||||
"additional_kutas": additional,
|
||||
"exceptions": exceptions,
|
||||
}
|
||||
|
||||
|
||||
def calc_synastry(male_chart: Dict, female_chart: Dict) -> Dict:
|
||||
"""
|
||||
Backward-compatible synastry wrapper used by integration tests and older CLI paths.
|
||||
|
||||
Expected chart keys:
|
||||
- moon_lon: Moon longitude in degrees (required)
|
||||
- mars_lon / asc_lon / gender: optional, reserved for Kuja and extended factors
|
||||
"""
|
||||
if "moon_lon" not in male_chart or "moon_lon" not in female_chart:
|
||||
raise ValueError("calc_synastry requires moon_lon in both male_chart and female_chart")
|
||||
|
||||
result = calc_ashtakoot(
|
||||
float(male_chart["moon_lon"]),
|
||||
float(female_chart["moon_lon"]),
|
||||
male_chart=male_chart,
|
||||
female_chart=female_chart,
|
||||
)
|
||||
|
||||
# Historical test/API compatibility: expose BadConstellations as an additional Kuta.
|
||||
# A bad constellation condition is present if Vedha or Rajju is adverse.
|
||||
vedha_bad = result["additional_kutas"].get("Vedha") == "bad"
|
||||
rajju_bad = result["additional_kutas"].get("Rajju", {}).get("result") == "bad"
|
||||
result["additional_kutas"]["BadConstellations"] = "bad" if (vedha_bad or rajju_bad) else "good"
|
||||
return result
|
||||
+31
-9
@@ -96,14 +96,36 @@ def _calc_formula_saham(
|
||||
second = _resolve_saham_operand(formula[1], planet_lons, asc_lon, computed)
|
||||
third = _resolve_saham_operand(formula[2], planet_lons, asc_lon, computed)
|
||||
result = (third + (first - second)) % 360
|
||||
# references/saham_rules.json: add one sign when Ascendant is not on the
|
||||
# forward zodiacal arc from the first formula point to the second.
|
||||
ascendant_between_points = (
|
||||
first <= asc_lon <= second
|
||||
if first <= second
|
||||
else asc_lon >= first or asc_lon <= second
|
||||
)
|
||||
return result if ascendant_between_points else (result + 30.0) % 360
|
||||
# PVR §28.8.1, p394: in A-B+C, C must lie on the forward arc B -> A.
|
||||
third_between_points = (third - second) % 360 <= (first - second) % 360
|
||||
return result if third_between_points else (result + 30.0) % 360
|
||||
|
||||
|
||||
def _formula_saham_trace(
|
||||
rule_name: str,
|
||||
planet_lons: Dict[str, float],
|
||||
asc_lon: float,
|
||||
is_day: bool,
|
||||
computed: Dict[str, float],
|
||||
) -> Dict:
|
||||
"""Calculate a rule-backed Saham while preserving its sensitive convention."""
|
||||
rule = _SAHAM_RULES['sahams'][rule_name]
|
||||
formula = rule['formula_day'] if is_day else rule['formula_night']
|
||||
first = _resolve_saham_operand(formula[0], planet_lons, asc_lon, computed)
|
||||
second = _resolve_saham_operand(formula[1], planet_lons, asc_lon, computed)
|
||||
third = _resolve_saham_operand(formula[2], planet_lons, asc_lon, computed)
|
||||
base_longitude = (third + (first - second)) % 360
|
||||
third_between_points = (third - second) % 360 <= (first - second) % 360
|
||||
exception_applied = not third_between_points
|
||||
return {
|
||||
'longitude': (base_longitude + 30.0) % 360 if exception_applied else base_longitude,
|
||||
'base_longitude': base_longitude,
|
||||
'rule_name': rule_name,
|
||||
'formula_operands': formula,
|
||||
'formula_variant': 'day' if is_day else 'night',
|
||||
'zodiacal_order_exception_applied': exception_applied,
|
||||
'rule_source': 'references/saham_rules.json',
|
||||
}
|
||||
|
||||
|
||||
def calc_muntha(birth_asc_idx: int, age: int) -> Dict:
|
||||
@@ -1592,7 +1614,7 @@ def calc_all_sahams(planet_lons: Dict[str, float],
|
||||
'boundary': 'No solar-house day/night proxy is permitted in production.',
|
||||
}
|
||||
from saham_daynight import determine_daytime
|
||||
daynight = determine_daytime(birth_dt, lat=float(lat), lon=float(lon), tz=float(tz))
|
||||
daynight = determine_daytime(birth_dt, lat=float(lat), lon=float(lon), tz=float(tz), rising="hindu")
|
||||
sun_lon = planet_lons.get('Sun', 0)
|
||||
moon_lon = planet_lons.get('Moon', 0)
|
||||
mars_lon = planet_lons.get('Mars', 0)
|
||||
|
||||
@@ -365,7 +365,7 @@ def _register_existing_techniques():
|
||||
module_path="transit.py", compute_func="computeTransit"),
|
||||
TechniqueSpec("synastry", "合盘系统", TechniqueCategory.SYNASTRY,
|
||||
TechniqueLevel.L2_STANDARD, TechniqueStatus.STABLE,
|
||||
module_path="synastry.py", compute_func="calc_synastry"),
|
||||
module_path="marriage_match.py", compute_func="build_marriage_match_dossier"),
|
||||
TechniqueSpec("prashna", "问事系统", TechniqueCategory.PRASHNA,
|
||||
TechniqueLevel.L2_STANDARD, TechniqueStatus.WIP,
|
||||
module_path="prashna_context.py", compute_func="build_prashna_context"),
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
"""Auditable orchestration for the native tropical Western calculation layers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
try: # reached as a script: this directory is on the path, the root is not
|
||||
import _script_root # noqa: F401
|
||||
except ModuleNotFoundError: # pragma: no cover - reached as scripts.x instead
|
||||
try:
|
||||
from scripts import _script_root # noqa: F401
|
||||
except ModuleNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from datetime import date
|
||||
from typing import Any, Callable
|
||||
|
||||
try:
|
||||
import swisseph as swe
|
||||
except ImportError: # pragma: no cover - optional runtime dependency
|
||||
swe = None
|
||||
|
||||
try:
|
||||
from scripts.classical_astrology import build_classical_astrology_packet, build_classical_usable_loop_audit_packet
|
||||
from scripts.western_chart_engine import build_tropical_natal_chart
|
||||
from scripts.western_chart_engine import build_draconic_chart, build_relocation_chart
|
||||
from scripts.western_timing_engine import (
|
||||
calculate_annual_profection,
|
||||
calculate_converse_secondary_progressions,
|
||||
calculate_converse_solar_arc_directions,
|
||||
calculate_lunar_return,
|
||||
calculate_lunar_return_series,
|
||||
calculate_midpoints,
|
||||
calculate_parans_status,
|
||||
calculate_secondary_progressions,
|
||||
calculate_solar_arc_directions,
|
||||
calculate_solar_return,
|
||||
calculate_tertiary_progressed_moon,
|
||||
calculate_transit_duration_scan,
|
||||
calculate_transit_to_natal,
|
||||
calculate_zodiacal_release,
|
||||
)
|
||||
except ModuleNotFoundError: # pragma: no cover - direct script execution
|
||||
from classical_astrology import build_classical_astrology_packet, build_classical_usable_loop_audit_packet
|
||||
from western_chart_engine import build_tropical_natal_chart
|
||||
from western_chart_engine import build_draconic_chart, build_relocation_chart
|
||||
from western_timing_engine import (
|
||||
calculate_annual_profection,
|
||||
calculate_converse_secondary_progressions,
|
||||
calculate_converse_solar_arc_directions,
|
||||
calculate_lunar_return,
|
||||
calculate_lunar_return_series,
|
||||
calculate_midpoints,
|
||||
calculate_parans_status,
|
||||
calculate_secondary_progressions,
|
||||
calculate_solar_arc_directions,
|
||||
calculate_solar_return,
|
||||
calculate_tertiary_progressed_moon,
|
||||
calculate_transit_duration_scan,
|
||||
calculate_transit_to_natal,
|
||||
calculate_zodiacal_release,
|
||||
)
|
||||
|
||||
|
||||
def _executed(inputs: dict[str, Any], calculation: Callable[[], dict[str, Any]]) -> dict[str, Any]:
|
||||
try:
|
||||
return {"status": "executed", "input": inputs, "raw_result": calculation()}
|
||||
except Exception as exc: # pragma: no cover - defensive orchestration boundary
|
||||
return {
|
||||
"status": "blocked",
|
||||
"input": inputs,
|
||||
"reason": f"{exc.__class__.__name__}: {exc}",
|
||||
}
|
||||
|
||||
|
||||
def _blocked(reason: str, inputs: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {"status": "blocked", "reason": reason}
|
||||
if inputs:
|
||||
result["input"] = inputs
|
||||
return result
|
||||
|
||||
|
||||
def _reference_layer(
|
||||
reference_date: str | None,
|
||||
birth: dict[str, Any],
|
||||
calculation: Callable[[str], dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
if not reference_date:
|
||||
return _blocked("reference_date is required")
|
||||
return _executed({"reference_date": reference_date}, lambda: calculation(reference_date))
|
||||
|
||||
|
||||
def _overall_status(layers: dict[str, dict[str, Any]]) -> str:
|
||||
return "complete" if all(layer["status"] == "executed" for layer in layers.values()) else "partial"
|
||||
|
||||
|
||||
def _eclipse_events(reference_date: str) -> dict[str, Any]:
|
||||
if swe is None:
|
||||
raise RuntimeError("swisseph is unavailable")
|
||||
start = date.fromisoformat(reference_date)
|
||||
jd = swe.julday(start.year, start.month, start.day, 0.0)
|
||||
events = []
|
||||
for kind, finder in (("solar", swe.sol_eclipse_when_glob), ("lunar", swe.lun_eclipse_when)):
|
||||
flags, times = finder(jd)
|
||||
year, month, day, hour = swe.revjul(times[0])
|
||||
events.append({"kind": kind, "maximum_utc": f"{year:04d}-{month:02d}-{day:02d}T{int(hour):02d}:{int((hour % 1) * 60):02d}:00Z", "swiss_event_flags": int(flags)})
|
||||
return {"technique": "eclipse_triggers", "reference_date": reference_date, "events": events, "boundary": "Geometric eclipse triggers only; no predictive interpretation is inferred."}
|
||||
|
||||
|
||||
def build_unified_western_reading(
|
||||
*,
|
||||
reference_date: str | None = None,
|
||||
duration_scan_end_date: str | None = None,
|
||||
lunar_return_months: int = 1,
|
||||
relocation_latitude: float | None = None,
|
||||
relocation_longitude: float | None = None,
|
||||
relocation_timezone: str | float | int | None = None,
|
||||
zodiacal_release_lot: str = "spirit",
|
||||
zodiacal_release_levels: int = 2,
|
||||
include_classical_usable_loop: bool = False,
|
||||
classical_event_window_hours: int = 4,
|
||||
classical_event_step_hours: int = 2,
|
||||
classical_blind_scoring_manifest: str | None = None,
|
||||
classical_reviewer_manifest: str | None = None,
|
||||
**birth: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Run every available native Western layer and preserve raw evidence.
|
||||
|
||||
``reference_date`` drives the one-day timing techniques. A duration scan
|
||||
additionally needs an explicit end date, so a caller cannot unknowingly
|
||||
request an unbounded scan. Eclipse geometry uses the local Swiss Ephemeris
|
||||
calculation and does not infer an event interpretation.
|
||||
"""
|
||||
layers: dict[str, dict[str, Any]] = {
|
||||
"natal": _executed(dict(birth), lambda: build_tropical_natal_chart(**birth)),
|
||||
"classical": _executed(
|
||||
{"reference_date": reference_date},
|
||||
lambda: build_classical_astrology_packet(reference_date=reference_date, **birth),
|
||||
),
|
||||
"draconic": _executed(dict(birth), lambda: build_draconic_chart(**birth)),
|
||||
"transits": _reference_layer(
|
||||
reference_date, birth, lambda target: calculate_transit_to_natal(target_date=target, **birth)
|
||||
),
|
||||
"solar_return": _reference_layer(
|
||||
reference_date,
|
||||
birth,
|
||||
lambda target: calculate_solar_return(target_year=date.fromisoformat(target).year, **birth),
|
||||
),
|
||||
"secondary_progressions": _reference_layer(
|
||||
reference_date, birth, lambda target: calculate_secondary_progressions(target_date=target, **birth)
|
||||
),
|
||||
"tertiary_moon": _reference_layer(
|
||||
reference_date, birth, lambda target: calculate_tertiary_progressed_moon(target_date=target, **birth)
|
||||
),
|
||||
"solar_arc": _reference_layer(
|
||||
reference_date, birth, lambda target: calculate_solar_arc_directions(target_date=target, **birth)
|
||||
),
|
||||
"midpoints": _reference_layer(
|
||||
reference_date, birth, lambda target: calculate_midpoints(target_date=target, **birth)
|
||||
),
|
||||
"lunar_return": _reference_layer(
|
||||
reference_date, birth, lambda target: calculate_lunar_return_series(
|
||||
target_date=target, months=lunar_return_months, **birth
|
||||
)
|
||||
),
|
||||
"parans": _reference_layer(
|
||||
reference_date, birth, lambda target: calculate_parans_status(target_date=target, **birth)
|
||||
),
|
||||
"profection": _reference_layer(
|
||||
reference_date, birth, lambda target: calculate_annual_profection(target_date=target, **birth)
|
||||
),
|
||||
"zodiacal_release": _reference_layer(
|
||||
reference_date,
|
||||
birth,
|
||||
lambda target: calculate_zodiacal_release(
|
||||
target_date=target,
|
||||
lot=zodiacal_release_lot,
|
||||
levels=int(zodiacal_release_levels),
|
||||
**birth,
|
||||
),
|
||||
),
|
||||
}
|
||||
if relocation_latitude is None or relocation_longitude is None or relocation_timezone is None:
|
||||
layers["relocation"] = _blocked(
|
||||
"relocation_latitude, relocation_longitude, and relocation_timezone are required"
|
||||
)
|
||||
else:
|
||||
layers["relocation"] = _executed(
|
||||
{
|
||||
"relocation_latitude": relocation_latitude,
|
||||
"relocation_longitude": relocation_longitude,
|
||||
"relocation_timezone": relocation_timezone,
|
||||
},
|
||||
lambda: build_relocation_chart(
|
||||
relocation_latitude=float(relocation_latitude),
|
||||
relocation_longitude=float(relocation_longitude),
|
||||
relocation_timezone=relocation_timezone,
|
||||
**birth,
|
||||
),
|
||||
)
|
||||
if reference_date:
|
||||
layers["converse"] = _executed(
|
||||
{"reference_date": reference_date},
|
||||
lambda: {
|
||||
"secondary_progressions": calculate_converse_secondary_progressions(
|
||||
target_date=reference_date, **birth
|
||||
),
|
||||
"solar_arc_directions": calculate_converse_solar_arc_directions(
|
||||
target_date=reference_date, **birth
|
||||
),
|
||||
},
|
||||
)
|
||||
else:
|
||||
layers["converse"] = _blocked("reference_date is required")
|
||||
|
||||
if not reference_date:
|
||||
layers["transit_duration"] = _blocked("reference_date is required")
|
||||
elif not duration_scan_end_date:
|
||||
layers["transit_duration"] = _blocked("duration_scan_end_date is required")
|
||||
else:
|
||||
layers["transit_duration"] = _executed(
|
||||
{"start_date": reference_date, "end_date": duration_scan_end_date},
|
||||
lambda: calculate_transit_duration_scan(
|
||||
start_date=reference_date, end_date=duration_scan_end_date, **birth
|
||||
),
|
||||
)
|
||||
layers["eclipses"] = _reference_layer(reference_date, birth, _eclipse_events)
|
||||
if include_classical_usable_loop:
|
||||
layers["classical_usable_loop"] = _executed(
|
||||
{
|
||||
"reference_date": reference_date,
|
||||
"event_window_hours": classical_event_window_hours,
|
||||
"event_step_hours": classical_event_step_hours,
|
||||
"blind_scoring_manifest": classical_blind_scoring_manifest,
|
||||
"reviewer_manifest": classical_reviewer_manifest,
|
||||
},
|
||||
lambda: build_classical_usable_loop_audit_packet(
|
||||
reference_date=reference_date,
|
||||
event_window_hours=int(classical_event_window_hours),
|
||||
event_step_hours=int(classical_event_step_hours),
|
||||
blind_scoring_manifest_path=classical_blind_scoring_manifest,
|
||||
reviewer_manifest_path=classical_reviewer_manifest,
|
||||
**birth,
|
||||
),
|
||||
)
|
||||
return {
|
||||
"system": "western_astrology",
|
||||
"status": _overall_status(layers),
|
||||
"reference_date": reference_date,
|
||||
"layers": layers,
|
||||
"boundary": "Raw native calculations only; this entrypoint makes no predictive interpretation.",
|
||||
}
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Run the unified native Western calculation layers.")
|
||||
parser.add_argument("--input-json", required=True, help="Structured birth and timing input as JSON.")
|
||||
args = parser.parse_args(argv)
|
||||
payload = json.loads(args.input_json)
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("--input-json must decode to an object")
|
||||
print(json.dumps(build_unified_western_reading(**payload), ensure_ascii=False, default=str))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
raise SystemExit(main())
|
||||
@@ -153,6 +153,21 @@ def _birth_zone(value: str | float | int):
|
||||
return fixed_timezone(timedelta(hours=offset)), f"UTC{offset:+g}"
|
||||
|
||||
|
||||
def _birth_times(
|
||||
*,
|
||||
year: int,
|
||||
month: int,
|
||||
day: int,
|
||||
hour: int,
|
||||
minute: int,
|
||||
second: int,
|
||||
timezone: str | float | int,
|
||||
) -> tuple[datetime, datetime, str]:
|
||||
zone, timezone_label = _birth_zone(timezone)
|
||||
local = datetime(year, month, day, hour, minute, second, tzinfo=zone)
|
||||
utc = local.astimezone(ZoneInfo("UTC"))
|
||||
return local, utc, timezone_label
|
||||
|
||||
def build_tropical_natal_chart(
|
||||
*,
|
||||
year: int,
|
||||
@@ -217,6 +232,100 @@ def build_tropical_natal_chart(
|
||||
}
|
||||
|
||||
|
||||
def build_draconic_chart(**birth: Any) -> dict[str, Any]:
|
||||
"""Derive a draconic chart by rotating tropical longitudes to the true node Aries point."""
|
||||
natal_chart = build_tropical_natal_chart(**birth)
|
||||
natal = natal_chart["natal"]
|
||||
node_longitude = natal["planets"]["true_node"]["longitude"]
|
||||
|
||||
def _rotate_point(point: dict[str, Any]) -> dict[str, Any]:
|
||||
rotated = _point(point["longitude"] - node_longitude)
|
||||
if "house" in point:
|
||||
rotated["house"] = point["house"]
|
||||
if "speed_longitude" in point:
|
||||
rotated["speed_longitude"] = point["speed_longitude"]
|
||||
rotated["retrograde"] = point.get("retrograde", False)
|
||||
return rotated
|
||||
|
||||
rotated_planets = {name: _rotate_point(point) for name, point in natal["planets"].items()}
|
||||
rotated_angles = {name: _rotate_point(point) for name, point in natal["angles"].items()}
|
||||
rotated_houses = [
|
||||
{"house": row["house"], "cusp": _rotate_point(row["cusp"])}
|
||||
for row in natal["houses"]
|
||||
]
|
||||
aspect_points = {**rotated_planets, "ascendant": rotated_angles["ascendant"], "mc": rotated_angles["mc"]}
|
||||
return {
|
||||
"technique": "draconic_chart",
|
||||
"status": "used",
|
||||
"source_engine": natal_chart["source_engine"],
|
||||
"base_zodiac": natal_chart["zodiac"],
|
||||
"derived_zodiac": "draconic",
|
||||
"node_basis": "true_node",
|
||||
"node_longitude": node_longitude,
|
||||
"draconic": {
|
||||
"ascendant": rotated_angles["ascendant"],
|
||||
"mc": rotated_angles["mc"],
|
||||
"angles": rotated_angles,
|
||||
"planets": rotated_planets,
|
||||
"houses": rotated_houses,
|
||||
"aspects": _aspects(aspect_points),
|
||||
"distribution": _distribution(rotated_planets),
|
||||
"house_ruler_chains": _ruler_chains(
|
||||
[row["cusp"]["longitude"] for row in rotated_houses],
|
||||
rotated_planets,
|
||||
),
|
||||
},
|
||||
"boundary": "Longitude rotation to the true-node Aries point only; interpretation and timing remain separate audited layers.",
|
||||
}
|
||||
|
||||
def build_relocation_chart(
|
||||
*,
|
||||
relocation_latitude: float,
|
||||
relocation_longitude: float,
|
||||
relocation_timezone: str | float | int,
|
||||
house_system: str = "P",
|
||||
**birth: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Calculate a relocated chart for the same UTC birth instant at a new location."""
|
||||
local, utc, _ = _birth_times(
|
||||
year=int(birth["year"]),
|
||||
month=int(birth["month"]),
|
||||
day=int(birth["day"]),
|
||||
hour=int(birth["hour"]),
|
||||
minute=int(birth["minute"]),
|
||||
second=int(birth.get("second", 0)),
|
||||
timezone=birth["timezone"],
|
||||
)
|
||||
relocation_zone, relocation_label = _birth_zone(relocation_timezone)
|
||||
relocation_local = utc.astimezone(relocation_zone)
|
||||
relocated_chart = build_tropical_natal_chart(
|
||||
year=relocation_local.year,
|
||||
month=relocation_local.month,
|
||||
day=relocation_local.day,
|
||||
hour=relocation_local.hour,
|
||||
minute=relocation_local.minute,
|
||||
second=relocation_local.second,
|
||||
latitude=float(relocation_latitude),
|
||||
longitude=float(relocation_longitude),
|
||||
timezone=relocation_timezone,
|
||||
house_system=house_system,
|
||||
)
|
||||
return {
|
||||
"technique": "relocation_chart",
|
||||
"status": "used",
|
||||
"source_engine": relocated_chart["source_engine"],
|
||||
"same_birth_utc": utc.isoformat(),
|
||||
"source_local_time": local.isoformat(),
|
||||
"relocation_local_time": relocation_local.isoformat(),
|
||||
"relocation_timezone": relocation_label,
|
||||
"relocation_coordinates": {
|
||||
"latitude": float(relocation_latitude),
|
||||
"longitude": float(relocation_longitude),
|
||||
},
|
||||
"relocated_chart": relocated_chart,
|
||||
"boundary": "Same UTC birth instant with relocated coordinates and timezone; interpretation and relocation outcome judgment remain separate audited layers.",
|
||||
}
|
||||
|
||||
def _request_field(body: dict[str, Any], *keys: str) -> Any:
|
||||
for key in keys:
|
||||
if key in body and body[key] not in (None, ""):
|
||||
|
||||
@@ -16,6 +16,25 @@ except ImportError:
|
||||
DEFAULT_MANIFEST = Path("references/real_case_calibration/replay_manifest.json")
|
||||
|
||||
|
||||
def _build_calibration_summary(primary_theme: str = "general") -> dict:
|
||||
"""Import shim for classical_astrology.
|
||||
|
||||
Upstream's scoring body is not in this tree. The caller only reads these
|
||||
keys and treats a missing gate as blocked, so full-reading can import the
|
||||
classical packet without inventing a specificity score.
|
||||
"""
|
||||
return {
|
||||
"status": "blocked",
|
||||
"reason": "specificity_gate_not_ported",
|
||||
"primary_theme": primary_theme,
|
||||
"false_positive_audit": {"frozen_negative_holdout": {"status": "blocked"}},
|
||||
"positive_replay": {},
|
||||
"positive_holdout": {},
|
||||
"domain_replay_scoring": {},
|
||||
"review_pack": {},
|
||||
}
|
||||
|
||||
|
||||
def build_case(case_id: str, manifest_path: Path = DEFAULT_MANIFEST) -> dict:
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
case = next((row for row in manifest["cases"] if row["case_id"] == case_id), None)
|
||||
|
||||
Reference in New Issue
Block a user