feat(upstream): merge a6f47abd engine, MCP, and orchestrator
Independent Staging Quality Gate / validate (push) Has been cancelled
Independent Staging Quality Gate / publish (push) Has been cancelled

Three-way-merge calculation modules and pl9-export into the product fork while keeping commercial API routes, Raman ayanamsa, and the consultation contract as a keypath superset.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-03 20:28:57 +08:00
co-authored by Cursor
parent 45d132588f
commit f224146348
30 changed files with 17151 additions and 248 deletions
+247 -32
View File
@@ -17,6 +17,9 @@ from datetime import datetime
import math
import json
import os
import shutil
import subprocess
import sys
SIGNS = ['Aries','Taurus','Gemini','Cancer','Leo','Virgo',
'Libra','Scorpio','Sagittarius','Capricorn','Aquarius','Pisces']
@@ -671,7 +674,7 @@ def calc_tajika_yogas(planet_lons: Dict[str, float],
results['yogas'].append(yoga)
# ── 2. Easarapha Yoga(分离瑜伽)──
# 定义:两行星同星座,快行星已离开慢行星(separate),度数差<=3度
# 定义:两行星同星座,且 shared motion contract 判定为 separating
for i, p1 in enumerate(Planet_LIST):
if p1 not in planet_lons:
continue
@@ -686,28 +689,23 @@ def calc_tajika_yogas(planet_lons: Dict[str, float],
fast = p1 if _is_faster(p1, p2) else p2
slow = p2 if fast == p1 else p1
fast_lon = planet_lons[fast]
slow_lon = planet_lons[slow]
# 分离:快行星已过慢行星(在慢行星"后面")
# 即慢行星度数 > 快行星度数(在同一圈内)
slow_in_sign = slow_lon % 30
fast_in_sign = fast_lon % 30
if slow_in_sign > fast_in_sign:
# 慢在前面,快在后面 → 分离状态
diff = slow_in_sign - fast_in_sign
if diff <= 3.0:
yoga = {
'type': 'Easarapha',
'fast_planet': fast,
'slow_planet': slow,
'degree_diff': round(diff, 4),
'sign': SIGNS[sign1],
'strength': 'strong' if diff <= 1.0 else 'moderate',
'interpretation': _easarapha_interp(fast, slow, diff),
}
results['easarapha'].append(yoga)
results['yogas'].append(yoga)
shared_pair = evaluate_tajika_pair(p1, p2, motion_payload)
if shared_pair is None or shared_pair.get('motion') != 'separating':
continue
diff = abs(float(shared_pair.get('residual', 0.0)))
yoga = {
'type': 'Easarapha',
'fast_planet': fast,
'slow_planet': slow,
'degree_diff': round(diff, 4),
'sign': SIGNS[sign1],
'strength': 'strong' if diff <= 1.0 else 'moderate',
'interpretation': _easarapha_interp(fast, slow, diff),
'shared_motion': shared_pair.get('motion'),
'shared_average_deeptamsa': shared_pair.get('average_deeptamsa'),
}
results['easarapha'].append(yoga)
results['yogas'].append(yoga)
# ── 3. Nakta Yoga(夜间瑜伽)──
# 定义:太阳和月亮同星座(任何度数)
@@ -909,6 +907,206 @@ def _is_daytime(birth_dt: datetime, sun_lon: float, asc_lon: float) -> bool:
return sun_house in (1, 2, 3, 4, 5, 6)
def _year_lord_is_daytime(
*,
annual_dt: Optional[datetime],
lat: Optional[float],
lon: Optional[float],
tz: Optional[float],
sun_lon: float,
asc_lon: float,
) -> bool:
if annual_dt is not None and lat is not None and lon is not None and tz is not None:
try:
from saham_daynight import determine_daytime
return bool(determine_daytime(annual_dt, lat=float(lat), lon=float(lon), tz=float(tz))['is_daytime'])
except Exception:
pass
if annual_dt is None:
annual_dt = datetime.utcnow()
return _is_daytime(annual_dt, sun_lon, asc_lon)
def _benefic_aspect_targets(source_sign_idx: int) -> List[int]:
source_sign_idx = int(source_sign_idx) % 12
return [
(source_sign_idx + 4) % 12,
(source_sign_idx + 8) % 12,
(source_sign_idx + 2) % 12,
(source_sign_idx + 10) % 12,
]
def _malefic_aspect_targets(source_sign_idx: int) -> List[int]:
source_sign_idx = int(source_sign_idx) % 12
return [
source_sign_idx,
(source_sign_idx + 3) % 12,
(source_sign_idx + 9) % 12,
(source_sign_idx + 6) % 12,
]
def _resolve_year_lord_panchavargiya_score(
planet: str,
lon_value: float,
*,
annual_jd: Optional[float],
lat: Optional[float],
lon: Optional[float],
tz: Optional[float],
) -> Dict:
external = _external_pyjhora_panchavargiya_score(
planet,
annual_jd=annual_jd,
lat=lat,
lon=lon,
tz=tz,
)
if external is not None:
return external
proxy = _calc_panchavargiya_bala_for_planet(planet, lon_value)
return {
'score': proxy['score'],
'engine': 'native_proxy',
'components': proxy['components'],
}
def _external_pyjhora_panchavargiya_score(
planet: str,
*,
annual_jd: Optional[float],
lat: Optional[float],
lon: Optional[float],
tz: Optional[float],
) -> Optional[Dict]:
if annual_jd is None or lat is None or lon is None or tz is None:
return None
try:
from jhora.panchanga import drik as j_drik
from jhora.horoscope.chart import strength as j_strength
place = j_drik.Place('annual_year_lord_probe', float(lat), float(lon), float(tz))
scores = j_strength.pancha_vargeeya_bala(float(annual_jd), place)
planet_idx = PLANET_TO_INDEX.get(planet)
if planet_idx is None:
return None
score = scores[planet_idx]
return {
'score': float(score),
'engine': 'pyjhora',
'components': {'pyjhora_pancha_vargeeya_bala': float(score)},
}
except Exception:
return _external_pyjhora_panchavargiya_score_sidecar(planet, annual_jd, lat, lon, tz)
def _external_pyjhora_panchavargiya_score_sidecar(
planet: str,
annual_jd: float,
lat: float,
lon: float,
tz: float,
) -> Optional[Dict]:
"""Read optional PyJHora Panchavargiya Bala scores via an external Python.
PyJHora stays an optional black-box oracle. We do not vendor it, import it
as a hard dependency, or fail native execution when it is absent.
"""
scores = _external_pyjhora_panchavargiya_scores_sidecar(annual_jd, lat, lon, tz)
if not scores or planet not in scores:
return None
score = scores[planet]
return {
'score': score,
'engine': 'pyjhora_sidecar',
'components': {
'pyjhora_pancha_vargeeya_bala_sidecar': score,
'scores_by_planet': scores,
'license_boundary': 'external_reference_only_do_not_vendor',
},
}
def _external_pyjhora_panchavargiya_scores_sidecar(
annual_jd: float,
lat: float,
lon: float,
tz: float,
) -> Optional[Dict[str, float]]:
key = (round(float(annual_jd), 8), round(float(lat), 6), round(float(lon), 6), round(float(tz), 4))
if key in _PYJHORA_PVB_SIDECAR_CACHE:
return _PYJHORA_PVB_SIDECAR_CACHE[key]
executable = _resolve_external_pyjhora_python()
if not executable:
_PYJHORA_PVB_SIDECAR_CACHE[key] = None
return None
code = """
import contextlib
import io
import json
import sys
with contextlib.redirect_stdout(io.StringIO()):
from jhora.panchanga import drik as j_drik
from jhora.horoscope.chart import strength as j_strength
place = j_drik.Place('annual_year_lord_probe', float(sys.argv[2]), float(sys.argv[3]), float(sys.argv[4]))
scores = j_strength.pancha_vargeeya_bala(float(sys.argv[1]), place)
print(json.dumps({str(k): float(v) for k, v in dict(scores).items()}))
"""
try:
completed = subprocess.run(
[executable, '-c', code, str(float(annual_jd)), str(float(lat)), str(float(lon)), str(float(tz))],
check=True,
capture_output=True,
text=True,
timeout=15,
)
parsed = json.loads(completed.stdout)
scores = {
INDEX_TO_PLANET[int(idx)]: float(score)
for idx, score in parsed.items()
if int(idx) in INDEX_TO_PLANET
}
_PYJHORA_PVB_SIDECAR_CACHE[key] = scores or None
return _PYJHORA_PVB_SIDECAR_CACHE[key]
except Exception:
_PYJHORA_PVB_SIDECAR_CACHE[key] = None
return None
def _resolve_external_pyjhora_python() -> Optional[str]:
candidates = [
os.environ.get('JYOTISH_PYJHORA_PYTHON'),
shutil.which('python3.14'),
shutil.which('python3'),
]
current = os.path.abspath(sys.executable)
for candidate in candidates:
if not candidate:
continue
candidate = os.path.abspath(candidate)
if not os.path.exists(candidate):
continue
# Do not compare realpaths here: a venv executable can resolve to the
# managed Python binary while still having a different site-packages
# view. The direct managed interpreter may have optional PyJHora even
# when the current venv does not.
if candidate == current:
continue
try:
completed = subprocess.run(
[candidate, '-c', "import importlib.util; raise SystemExit(0 if importlib.util.find_spec('jhora') else 1)"],
capture_output=True,
text=True,
timeout=8,
)
except Exception:
continue
if completed.returncode == 0:
return candidate
return None
def calc_all_sahams(planet_lons: Dict[str, float],
asc_lon: float,
birth_dt: datetime,
@@ -1239,10 +1437,30 @@ def detect_tajika_yogas(varsha_planets: Dict, year_lord: str = None) -> List[Dic
"""计算两星间的有效容许度(取较小者)"""
return min(TAJIKA_ORBS.get(p1, 7), TAJIKA_ORBS.get(p2, 7))
def _build_shared_motion_payload():
try:
from scripts.tajika_kernel import evaluate_tajika_pair
except ModuleNotFoundError: # pragma: no cover
from tajika_kernel import evaluate_tajika_pair
speeds = {
'Moon': 13.176, 'Mercury': 4.092, 'Venus': 1.602,
'Sun': 0.986, 'Mars': 0.524, 'Jupiter': 0.083, 'Saturn': 0.034,
}
payload = {
planet: {
'longitude': _get_longitude(planet),
'speed': speeds.get(planet, 0.0),
}
for planet in SEVEN
}
return evaluate_tajika_pair, payload
# ── 1. Ithasala Yoga(连接瑜伽)完整版 ──
# 条件:快星追赶慢星(applying),orb ≤ 有效容许度
ithasala_pairs = []
checked = set()
evaluate_tajika_pair, motion_payload = _build_shared_motion_payload()
for p1 in SEVEN:
for p2 in SEVEN:
if p1 >= p2:
@@ -1261,17 +1479,14 @@ def detect_tajika_yogas(varsha_planets: Dict, year_lord: str = None) -> List[Dic
fast = p1 if _is_faster(p1, p2) else p2
slow = p2 if fast == p1 else p1
fast_lon = _get_longitude(fast)
slow_lon = _get_longitude(slow)
# 判断是否applying(快追慢)
# 快星度数 < 慢星度数(同一方向)= applying
applying = (fast_lon % 30) < (slow_lon % 30)
if applying or orb <= 3.0: # 3°内视为紧密连接
shared_pair = evaluate_tajika_pair(p1, p2, motion_payload)
if shared_pair is not None and shared_pair.get('motion') == 'applying':
ithasala_pairs.append({
'fast': fast, 'slow': slow, 'orb': orb,
'fast_lon': fast_lon, 'slow_lon': slow_lon,
'fast_lon': _get_longitude(fast), 'slow_lon': _get_longitude(slow),
'shared_motion': shared_pair.get('motion'),
'shared_average_deeptamsa': shared_pair.get('average_deeptamsa'),
'shared_residual': shared_pair.get('residual'),
})
for pair in ithasala_pairs: