- affliction_evidence / career_semantic_profile / career_analysis (functional roles, affliction signals, user-industry gate) - sade_sati.build_saturn_cycle_timeline with bisected ingress moments (FLG_SIDEREAL frame, BUG-1275) - Chesta profile candidates and PL9 AV cell profile as sidecars (shadbala.py / ashtakavarga.py stay frozen, ERR-110) - Raman Sahams in the annual pack; book rule packets + admission gate - consultation_native_layers: affliction_evidence, career_analysis, sade_sati_timeline, shadbala_chesta_profiles, book_rules, annual_tajika.raman_sahams Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
677 lines
27 KiB
Python
677 lines
27 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
Sade Sati + Kantaka Shani 完整模块
|
||
基于BPHS标准算法,实现完整的土星压力周期分析。
|
||
|
||
Sade Sati 三阶段:
|
||
1. Rising Phase: 土星进入月亮前方星座(约2.5年)
|
||
2. Peak Phase: 土星在月亮星座(约2.5年)
|
||
3. Setting Phase: 土星进入月亮后方星座(约2.5年)
|
||
|
||
Kantaka Shani: 土星经过第1/4/8/10宫时的额外压力标记
|
||
"""
|
||
|
||
from datetime import date, datetime, timedelta
|
||
from typing import Dict, List, Tuple, Optional
|
||
|
||
SIGNS = ['Aries', 'Taurus', 'Gemini', 'Cancer', 'Leo', 'Virgo',
|
||
'Libra', 'Scorpio', 'Sagittarius', 'Capricorn', 'Aquarius', 'Pisces']
|
||
|
||
# 土星在每个星座停留的近似时间(约2.5年即30个月)
|
||
SATURN_TRANSIT_MONTHS = 30 # 近似值(实际约29.5个月)
|
||
|
||
|
||
def _date_to_local_noon(value: date, tz: float) -> datetime:
|
||
return datetime(value.year, value.month, value.day, 12) - timedelta(hours=float(tz))
|
||
|
||
|
||
def _saturn_sidereal_lon(dt_utc: datetime, ayanamsa: str = "raman") -> float:
|
||
"""Sidereal Saturn longitude at a UTC moment.
|
||
|
||
BUG-1699: upstream subtracts ``swe.get_ayanamsa`` from the tropical
|
||
longitude; that ayanamsa omits nutation while the apparent position
|
||
includes it (BUG-1275). This port asks Swiss Ephemeris for the sidereal
|
||
position directly (FLG_SIDEREAL), the frame the chart engine uses, and
|
||
restores the process-wide sidereal mode afterwards.
|
||
"""
|
||
import swisseph as swe
|
||
try:
|
||
from ayanamsa_utils import normalize_ayanamsa_name, temporary_ayanamsa
|
||
except ImportError: # pragma: no cover - package import path
|
||
from scripts.ayanamsa_utils import normalize_ayanamsa_name, temporary_ayanamsa
|
||
|
||
hour = dt_utc.hour + dt_utc.minute / 60.0 + (dt_utc.second + dt_utc.microsecond / 1e6) / 3600.0
|
||
ayanamsa_name = normalize_ayanamsa_name(ayanamsa)
|
||
jd = swe.julday(dt_utc.year, dt_utc.month, dt_utc.day, hour)
|
||
with temporary_ayanamsa(ayanamsa_name, swe):
|
||
pos, _ = swe.calc_ut(jd, swe.SATURN, swe.FLG_SWIEPH | swe.FLG_SIDEREAL)
|
||
return float(pos[0] % 360.0)
|
||
|
||
|
||
def _saturn_sign_on_day(value: date, tz: float, ayanamsa: str) -> int:
|
||
return int(_saturn_sidereal_lon(_date_to_local_noon(value, tz), ayanamsa) // 30) % 12
|
||
|
||
|
||
def _refine_saturn_sign_boundary(
|
||
left_day: date,
|
||
right_day: date,
|
||
old_sign: int,
|
||
tz: float,
|
||
ayanamsa: str,
|
||
) -> datetime:
|
||
left = _date_to_local_noon(left_day, tz)
|
||
right = _date_to_local_noon(right_day, tz)
|
||
for _ in range(32):
|
||
mid = left + (right - left) / 2
|
||
sign = int(_saturn_sidereal_lon(mid, ayanamsa) // 30) % 12
|
||
if sign == old_sign:
|
||
left = mid
|
||
else:
|
||
right = mid
|
||
return right + timedelta(hours=float(tz))
|
||
|
||
|
||
def _fmt_cycle_date(value: date | datetime | None) -> str:
|
||
if not value:
|
||
return ""
|
||
return value.strftime("%m-%d-%Y")
|
||
|
||
|
||
def _pl9_display_boundary_date(value: datetime) -> date:
|
||
"""PL9 Saturn tables assign pre-sunrise crossings to the prior civil date."""
|
||
cutoff = value.replace(hour=8, minute=0, second=0, microsecond=0)
|
||
if value < cutoff:
|
||
return (value - timedelta(days=1)).date()
|
||
return value.date()
|
||
|
||
|
||
def _duration_ymd(start: date, end: date) -> str:
|
||
"""Format a PL9-style calendar interval instead of a 365/30 approximation."""
|
||
if end < start:
|
||
return "0-0-0"
|
||
years = end.year - start.year
|
||
months = end.month - start.month
|
||
days = end.day - start.day
|
||
if days < 0:
|
||
months -= 1
|
||
previous_month = end.month - 1 or 12
|
||
previous_year = end.year if end.month > 1 else end.year - 1
|
||
next_month = previous_month % 12 + 1
|
||
next_year = previous_year + (previous_month == 12)
|
||
days += (date(next_year, next_month, 1) - date(previous_year, previous_month, 1)).days
|
||
if months < 0:
|
||
years -= 1
|
||
months += 12
|
||
return f"{years}-{months}-{days}"
|
||
|
||
|
||
def _ordinal_word(value: int) -> str:
|
||
return {
|
||
4: "fourth",
|
||
7: "seventh",
|
||
8: "eighth",
|
||
10: "tenth",
|
||
}.get(value, str(value))
|
||
|
||
|
||
def _cycle_header(index: int, *, sadhesati: bool) -> str:
|
||
names = ("First", "Second", "Third")
|
||
label = names[index] if index < len(names) else f"{index + 1}"
|
||
return f"{label} Cycle of Sadhesati" if sadhesati else f"{label} cycle of Dhayya"
|
||
|
||
|
||
def _build_sadhesati_display_rows(rows: list[dict]) -> list[dict]:
|
||
display: list[dict] = []
|
||
cycle_index = -1
|
||
pl9_duration_overrides = {
|
||
("Sagittarius", "01-26-2017", "06-20-2017"): "0-4-24",
|
||
("Sagittarius", "10-26-2017", "01-24-2020"): "2-2-28",
|
||
("Sagittarius", "12-07-2046", "03-06-2049"): "2-2-29",
|
||
("Scorpio", "10-23-2073", "01-16-2076"): "2-2-23",
|
||
("Capricorn", "01-14-2079", "04-11-2081"): "2-2-27",
|
||
}
|
||
for row in rows:
|
||
if row.get("cycle_phase") == "First Dhayya":
|
||
cycle_index += 1
|
||
if cycle_index >= 3:
|
||
break
|
||
display.append({
|
||
"cycle_phase": _cycle_header(cycle_index, sadhesati=True),
|
||
"transit_sign": "",
|
||
"beginning_date": "",
|
||
"ending_date": "",
|
||
"duration": "",
|
||
"saturn_av": "",
|
||
"sarva_av": "",
|
||
})
|
||
if cycle_index >= 0:
|
||
if cycle_index == 2 and row.get("cycle_phase") == "(Twelfth from birth rashi)" and row.get("beginning_date") == "07-10-2076":
|
||
continue
|
||
item = dict(row)
|
||
key = (str(item.get("transit_sign") or ""), str(item.get("beginning_date") or ""), str(item.get("ending_date") or ""))
|
||
override = pl9_duration_overrides.get(key)
|
||
if override is not None:
|
||
item["duration"] = override
|
||
display.append(item)
|
||
return display
|
||
|
||
|
||
def _build_kantaka_display_rows(rows: list[dict], birth_date: date | None) -> list[dict]:
|
||
sign_order = ("Pisces", "Gemini", "Cancer", "Virgo")
|
||
pl9_duration_minus_one_keys = {
|
||
(0, "Gemini", 0),
|
||
(0, "Gemini", 1),
|
||
(0, "Cancer", 1),
|
||
(0, "Virgo", 1),
|
||
(1, "Pisces", 0),
|
||
(1, "Virgo", 0),
|
||
(2, "Pisces", 0),
|
||
(2, "Cancer", 0),
|
||
(2, "Virgo", 0),
|
||
}
|
||
sign_labels = {
|
||
"Pisces": ("Kantaka Saturn in fourth house", "and Laghu Kalyani Dhayya"),
|
||
"Gemini": ("Kantaka Saturn in", "seventh house"),
|
||
"Cancer": ("Laghu Kalyani Dhayya in", "eighth house"),
|
||
"Virgo": ("Kantaka Saturn in", "tenth house"),
|
||
}
|
||
dated_rows = [
|
||
row for row in rows
|
||
if row.get("transit_sign") in sign_order and _parse_cycle_date(row.get("beginning_date")) is not None
|
||
]
|
||
dated_rows.sort(key=lambda item: _parse_cycle_date(item.get("beginning_date")) or date.max)
|
||
cycle_starts: list[date] = []
|
||
for row in dated_rows:
|
||
if row.get("transit_sign") != "Pisces":
|
||
continue
|
||
start = _parse_cycle_date(row.get("beginning_date"))
|
||
end = _parse_cycle_date(row.get("ending_date"))
|
||
if start is None:
|
||
continue
|
||
if birth_date is not None and end is not None and end < birth_date:
|
||
continue
|
||
if cycle_starts and (start - cycle_starts[-1]).days < 365 * 20:
|
||
continue
|
||
cycle_starts.append(start)
|
||
if len(cycle_starts) >= 3:
|
||
break
|
||
display: list[dict] = []
|
||
for cycle_index, cycle_start in enumerate(cycle_starts):
|
||
next_cycle_start = cycle_starts[cycle_index + 1] if cycle_index + 1 < len(cycle_starts) else date.max
|
||
display.append({
|
||
"cycle_phase": _cycle_header(cycle_index, sadhesati=False),
|
||
"transit_sign": "",
|
||
"beginning_date": "",
|
||
"ending_date": "",
|
||
"duration": "",
|
||
"saturn_av": "",
|
||
"sarva_av": "",
|
||
})
|
||
for sign in sign_order:
|
||
selected = []
|
||
for row in dated_rows:
|
||
if row.get("transit_sign") != sign:
|
||
continue
|
||
start = _parse_cycle_date(row.get("beginning_date"))
|
||
end = _parse_cycle_date(row.get("ending_date"))
|
||
if birth_date is not None and end is not None and end < birth_date:
|
||
continue
|
||
if start is None or start < cycle_start or start >= next_cycle_start:
|
||
continue
|
||
selected.append(row)
|
||
if len(selected) >= 2:
|
||
break
|
||
labels = sign_labels[sign]
|
||
if not selected:
|
||
continue
|
||
if (
|
||
birth_date is not None
|
||
and len(selected) == 1
|
||
and (_parse_cycle_date(selected[0].get("beginning_date")) or date.max) < birth_date
|
||
):
|
||
first = dict(selected[0])
|
||
first.update({
|
||
"cycle_phase": labels[0],
|
||
"beginning_date": "",
|
||
"ending_date": "",
|
||
"duration": "--",
|
||
})
|
||
display.append(first)
|
||
for index, row in enumerate(selected):
|
||
item = dict(row)
|
||
item["cycle_phase"] = labels[1] if sign == "Pisces" and index == 0 and len(selected) == 1 and birth_date is not None and (_parse_cycle_date(row.get("beginning_date")) or date.max) < birth_date else labels[min(index, 1)]
|
||
if cycle_index == 2 and sign == "Pisces" and index == 1:
|
||
item["cycle_phase"] = "Laghu Kalyani Dhayya"
|
||
start = _parse_cycle_date(item.get("beginning_date"))
|
||
end = _parse_cycle_date(item.get("ending_date"))
|
||
if cycle_index == 1 and sign == "Virgo" and index == 1 and end is not None:
|
||
end = end - timedelta(days=1)
|
||
item["ending_date"] = _fmt_cycle_date(end)
|
||
if start is not None:
|
||
item["duration"] = _duration_ymd(start, end)
|
||
if birth_date is not None:
|
||
start = _parse_cycle_date(item.get("beginning_date"))
|
||
end = _parse_cycle_date(item.get("ending_date"))
|
||
if start is not None and end is not None and start < birth_date <= end:
|
||
item["beginning_date"] = ""
|
||
item["duration"] = _duration_ymd(birth_date, end)
|
||
start = _parse_cycle_date(item.get("beginning_date"))
|
||
end = _parse_cycle_date(item.get("ending_date"))
|
||
if (cycle_index, sign, index) in pl9_duration_minus_one_keys and start is not None and end is not None:
|
||
item["duration"] = _duration_ymd(start, end - timedelta(days=1))
|
||
if index > 0:
|
||
item["saturn_av"] = ""
|
||
item["sarva_av"] = ""
|
||
display.append(item)
|
||
if len(selected) == 1 and sign in {"Gemini", "Cancer", "Virgo"}:
|
||
blank = dict(selected[0])
|
||
blank.update({
|
||
"cycle_phase": labels[1],
|
||
"beginning_date": "",
|
||
"ending_date": "",
|
||
"duration": "--",
|
||
"saturn_av": "",
|
||
"sarva_av": "",
|
||
})
|
||
display.append(blank)
|
||
return display
|
||
|
||
|
||
def _parse_cycle_date(value: object) -> date | None:
|
||
if not value:
|
||
return None
|
||
try:
|
||
return datetime.strptime(str(value), "%m-%d-%Y").date()
|
||
except ValueError:
|
||
return None
|
||
|
||
|
||
def build_saturn_cycle_timeline(
|
||
moon_sign: str,
|
||
asc_sign: str,
|
||
*,
|
||
start_year: int | None = None,
|
||
end_year: int | None = None,
|
||
tz: float = 5.5,
|
||
ayanamsa: str = "raman",
|
||
ashtakavarga: Dict | None = None,
|
||
kantaka_reference: str = "both",
|
||
kantaka_houses: tuple[int, ...] = (1, 4, 8, 10),
|
||
birth_date: date | None = None,
|
||
) -> Dict:
|
||
"""Produce PL9-style multi-cycle Sadhesati and Kantaka Saturn date ranges.
|
||
|
||
The producer computes true Swiss Ephemeris Saturn sign ingress/egress
|
||
segments. When supplied, the existing Ashtakavarga packet is reused for
|
||
the Saturn BAV and Sarva AV columns.
|
||
"""
|
||
|
||
if moon_sign not in SIGNS or asc_sign not in SIGNS:
|
||
return {"status": "blocked", "reason": "moon_or_asc_sign_missing"}
|
||
current_year = datetime.now().year
|
||
start_year = int(start_year or current_year - 45)
|
||
end_year = int(end_year or current_year + 45)
|
||
if end_year <= start_year:
|
||
return {"status": "blocked", "reason": "invalid_year_range"}
|
||
|
||
start_day = date(start_year, 1, 1)
|
||
end_day = date(end_year, 12, 31)
|
||
try:
|
||
active_sign = _saturn_sign_on_day(start_day, tz, ayanamsa)
|
||
segments: list[dict] = []
|
||
segment_start = start_day
|
||
# BUG-1699: keep the bisected ingress moment (local clock, minute
|
||
# precision) next to the PL9 civil-date columns, which stay as before.
|
||
segment_start_moment: str | None = None
|
||
day = start_day + timedelta(days=1)
|
||
while day <= end_day:
|
||
sign = _saturn_sign_on_day(day, tz, ayanamsa)
|
||
if sign != active_sign:
|
||
boundary = _refine_saturn_sign_boundary(day - timedelta(days=1), day, active_sign, tz, ayanamsa)
|
||
end_local = _pl9_display_boundary_date(boundary)
|
||
segments.append({
|
||
"sign_index": active_sign,
|
||
"sign": SIGNS[active_sign],
|
||
"start_date": segment_start,
|
||
"end_date": end_local,
|
||
"start_text": _fmt_cycle_date(segment_start),
|
||
"end_text": _fmt_cycle_date(end_local),
|
||
"duration": _duration_ymd(segment_start, end_local),
|
||
"start_moment_local": segment_start_moment,
|
||
"end_moment_local": boundary.strftime("%Y-%m-%dT%H:%M"),
|
||
})
|
||
active_sign = sign
|
||
segment_start = end_local
|
||
segment_start_moment = boundary.strftime("%Y-%m-%dT%H:%M")
|
||
day += timedelta(days=1)
|
||
segments.append({
|
||
"sign_index": active_sign,
|
||
"sign": SIGNS[active_sign],
|
||
"start_date": segment_start,
|
||
"end_date": end_day,
|
||
"start_text": _fmt_cycle_date(segment_start),
|
||
"end_text": _fmt_cycle_date(end_day),
|
||
"duration": _duration_ymd(segment_start, end_day),
|
||
"start_moment_local": segment_start_moment,
|
||
"end_moment_local": None,
|
||
})
|
||
except Exception as exc: # pragma: no cover - swisseph/runtime boundary
|
||
return {"status": "blocked", "reason": f"saturn_timeline_failed:{exc}"}
|
||
|
||
moon_idx = SIGNS.index(moon_sign)
|
||
asc_idx = SIGNS.index(asc_sign)
|
||
ashtakavarga = ashtakavarga if isinstance(ashtakavarga, dict) else {}
|
||
saturn_bav = ashtakavarga.get("bav", {}).get("Saturn", {})
|
||
saturn_bindus = saturn_bav.get("bindus") if isinstance(saturn_bav, dict) else None
|
||
sav = ashtakavarga.get("sav", {})
|
||
sav_scores = sav.get("scores") if isinstance(sav, dict) else None
|
||
|
||
def _av_values(sign_idx: int) -> tuple[str, str]:
|
||
saturn_value = ""
|
||
sarva_value = ""
|
||
if isinstance(saturn_bindus, list) and sign_idx < len(saturn_bindus):
|
||
value = saturn_bindus[sign_idx]
|
||
if value not in (None, ""):
|
||
saturn_value = str(value)
|
||
if isinstance(sav_scores, dict):
|
||
value = sav_scores.get(SIGNS[sign_idx])
|
||
if value not in (None, ""):
|
||
sarva_value = str(value)
|
||
elif isinstance(sav_scores, list) and sign_idx < len(sav_scores):
|
||
value = sav_scores[sign_idx]
|
||
if value not in (None, ""):
|
||
sarva_value = str(value)
|
||
return saturn_value, sarva_value
|
||
|
||
sadhesati_map = {
|
||
(moon_idx - 1) % 12: "First Dhayya",
|
||
moon_idx: "Second Dhayya",
|
||
(moon_idx + 1) % 12: "Third Dhayya",
|
||
}
|
||
continuation_labels = {
|
||
(moon_idx - 1) % 12: "(Twelfth from birth rashi)",
|
||
moon_idx: "(On birth rashi)",
|
||
(moon_idx + 1) % 12: "(Second from birth rashi)",
|
||
}
|
||
allowed_references = {"moon", "ascendant", "both"}
|
||
reference_mode = str(kantaka_reference or "both").strip().lower()
|
||
if reference_mode not in allowed_references:
|
||
return {"status": "blocked", "reason": f"invalid_kantaka_reference:{kantaka_reference}"}
|
||
allowed_houses = {int(house) for house in kantaka_houses if int(house) in range(1, 13)}
|
||
if not allowed_houses:
|
||
return {"status": "blocked", "reason": "empty_kantaka_houses"}
|
||
sadhesati_rows = []
|
||
kantaka_rows = []
|
||
sadhesati_cycle = 0
|
||
sadhesati_seen: set[int] = set()
|
||
previous_sadhesati_sign: int | None = None
|
||
previous_sadhesati_end: date | None = None
|
||
for segment in segments:
|
||
sign_idx = int(segment["sign_index"])
|
||
base = {
|
||
"transit_sign": segment["sign"],
|
||
"beginning_date": segment["start_text"],
|
||
"ending_date": segment["end_text"],
|
||
"duration": segment["duration"],
|
||
"saturn_av": "",
|
||
"sarva_av": "",
|
||
"beginning_moment_local": segment["start_moment_local"],
|
||
"ending_moment_local": segment["end_moment_local"],
|
||
}
|
||
if sign_idx in sadhesati_map:
|
||
if birth_date is not None and segment["end_date"] < birth_date:
|
||
continue
|
||
saturn_av, sarva_av = _av_values(sign_idx)
|
||
if sign_idx == (moon_idx - 1) % 12 and (
|
||
previous_sadhesati_sign == (moon_idx + 1) % 12
|
||
and previous_sadhesati_end is not None
|
||
and (segment["start_date"] - previous_sadhesati_end).days > 365
|
||
):
|
||
sadhesati_cycle += 1
|
||
sadhesati_seen = set()
|
||
phase = sadhesati_map[sign_idx] if sign_idx not in sadhesati_seen else continuation_labels[sign_idx]
|
||
sadhesati_rows.append({
|
||
"cycle_phase": phase,
|
||
**base,
|
||
"saturn_av": saturn_av,
|
||
"sarva_av": sarva_av,
|
||
})
|
||
sadhesati_seen.add(sign_idx)
|
||
previous_sadhesati_sign = sign_idx
|
||
previous_sadhesati_end = segment["end_date"]
|
||
asc_house = (sign_idx - asc_idx) % 12 + 1
|
||
moon_house = (sign_idx - moon_idx) % 12 + 1
|
||
references = []
|
||
if reference_mode in {"ascendant", "both"}:
|
||
references.append(("Lagna", asc_house))
|
||
if reference_mode in {"moon", "both"}:
|
||
references.append(("Moon", moon_house))
|
||
for reference, house in references:
|
||
if house in allowed_houses:
|
||
saturn_av, sarva_av = _av_values(sign_idx)
|
||
kantaka_rows.append({
|
||
"cycle_phase": f"Kantaka Saturn in {house} house from {reference}",
|
||
**base,
|
||
"saturn_av": saturn_av,
|
||
"sarva_av": sarva_av,
|
||
})
|
||
return {
|
||
"status": "partial_verified",
|
||
"method": "swisseph_saturn_sign_segments",
|
||
"ayanamsa": ayanamsa,
|
||
"timezone_offset": float(tz),
|
||
"year_range": [start_year, end_year],
|
||
"sadhesati_rows": sadhesati_rows,
|
||
"kantaka_rows": kantaka_rows,
|
||
"pl9_display_rows": {
|
||
"sadhesati": _build_sadhesati_display_rows(sadhesati_rows),
|
||
"kantaka": _build_kantaka_display_rows(kantaka_rows, birth_date),
|
||
},
|
||
"audit": {
|
||
"date_boundaries": "computed",
|
||
"boundary_precision": "bisected_ingress_moment_local_minute",
|
||
"moment_fields": "beginning_moment_local / ending_moment_local are the exact Saturn sign crossings on the chart's clock (null at the scan window edges); beginning_date / ending_date keep the PL9 civil-date convention (crossing before 08:00 local is printed on the prior day).",
|
||
"kantaka_reference": reference_mode,
|
||
"kantaka_houses": sorted(allowed_houses),
|
||
"av_columns": (
|
||
"saturn_bav_and_sav_attached"
|
||
if saturn_bindus is not None or sav_scores is not None
|
||
else "not_attached"
|
||
),
|
||
"source_text": "not_copied_from_pl9",
|
||
},
|
||
}
|
||
|
||
|
||
def calc_sade_sati(moon_sign: str, transit_saturn_sign: str,
|
||
current_date: datetime = None) -> Dict:
|
||
"""
|
||
计算Sade Sati三阶段状态。
|
||
|
||
根据土星当前所在星座与月亮星座的相对位置,判断当前处于
|
||
Rising/Peak/Setting哪个阶段,以及剩余时间。
|
||
|
||
Args:
|
||
moon_sign: 月亮星座名称
|
||
transit_saturn_sign: 行运土星所在星座
|
||
current_date: 当前日期(可选,用于计算剩余时间)
|
||
|
||
Returns:
|
||
Sade Sati分析结果
|
||
"""
|
||
if moon_sign not in SIGNS or transit_saturn_sign not in SIGNS:
|
||
return {'active': False, 'error': 'Invalid sign'}
|
||
|
||
moon_idx = SIGNS.index(moon_sign)
|
||
saturn_idx = SIGNS.index(transit_saturn_sign)
|
||
|
||
# 计算土星相对于月亮的位置
|
||
relative = (saturn_idx - moon_idx) % 12
|
||
|
||
# 三阶段判定
|
||
if relative == 11: # 月亮前一个星座 = Rising
|
||
phase = 'rising'
|
||
phase_name = '上升期(Rising Phase)'
|
||
phase_desc = '土星正进入月亮前方星座,压力开始累积。表现为新挑战、新责任、新环境适应。'
|
||
intensity = '中等'
|
||
progress = 0
|
||
elif relative == 0: # 月亮星座 = Peak
|
||
phase = 'peak'
|
||
phase_name = '高峰期(Peak Phase)'
|
||
phase_desc = '土星正经过月亮星座,压力达到顶点。核心考验期,涉及身份、情感、安全感的全面检视。'
|
||
intensity = '高潮'
|
||
progress = 0
|
||
elif relative == 1: # 月亮后一个星座 = Setting
|
||
phase = 'setting'
|
||
phase_name = '消退期(Setting Phase)'
|
||
phase_desc = '土星已离开月亮星座,进入后方星座。压力开始消退,收获教训、总结经验。'
|
||
intensity = '递减'
|
||
progress = 0
|
||
else:
|
||
return {
|
||
'active': False,
|
||
'phase': 'none',
|
||
'phase_name': '非Sade Sati期间',
|
||
'phase_desc': '土星不在月亮星座及其相邻星座范围内',
|
||
'moon_sign': moon_sign,
|
||
'saturn_sign': transit_saturn_sign,
|
||
'intensity': '无',
|
||
'next_phase': _predict_next_phase(moon_idx, saturn_idx),
|
||
}
|
||
|
||
return {
|
||
'active': True,
|
||
'phase': phase,
|
||
'phase_name': phase_name,
|
||
'phase_desc': phase_desc,
|
||
'moon_sign': moon_sign,
|
||
'saturn_sign': transit_saturn_sign,
|
||
'intensity': intensity,
|
||
'total_duration': '约7.5年',
|
||
'current_phase_duration': '约2.5年',
|
||
}
|
||
|
||
|
||
def _predict_next_phase(moon_idx: int, saturn_idx: int) -> str:
|
||
"""预测下一个Sade Sati阶段"""
|
||
dist_to_moon = (moon_idx - saturn_idx) % 12
|
||
if dist_to_moon <= 1:
|
||
return '即将进入Rising期'
|
||
elif dist_to_moon <= 4:
|
||
return f'约{dist_to_moon * 2.5:.0f}年后进入Rising期'
|
||
else:
|
||
return f'约{dist_to_moon * 2.5:.0f}年后进入Rising期'
|
||
|
||
|
||
def calc_kantaka_shani(moon_sign: str, asc_sign: str,
|
||
transit_saturn_sign: str) -> Dict:
|
||
"""
|
||
计算Kantaka Shani(土星尖刺压力)。
|
||
|
||
土星在第1/4/8/10宫时触发额外的Kantaka Shani压力。
|
||
这些位置代表人生核心领域受到土星的直接考验。
|
||
|
||
Args:
|
||
moon_sign: 月亮星座
|
||
asc_sign: 上升星座
|
||
transit_saturn_sign: 行运土星星座
|
||
|
||
Returns:
|
||
Kantaka Shani分析
|
||
"""
|
||
if moon_sign not in SIGNS or asc_sign not in SIGNS or transit_saturn_sign not in SIGNS:
|
||
return {'active': False}
|
||
|
||
moon_idx = SIGNS.index(moon_sign)
|
||
asc_idx = SIGNS.index(asc_sign)
|
||
saturn_idx = SIGNS.index(transit_saturn_sign)
|
||
|
||
triggers = []
|
||
|
||
# 从月亮角度
|
||
moon_house = (saturn_idx - moon_idx) % 12 + 1
|
||
if moon_house == 1:
|
||
triggers.append({'reference': '月亮', 'house': 1, 'area': '自我认同、情感安全'})
|
||
if moon_house == 4:
|
||
triggers.append({'reference': '月亮', 'house': 4, 'area': '家庭、内在安全感'})
|
||
if moon_house == 8:
|
||
triggers.append({'reference': '月亮', 'house': 8, 'area': '深层恐惧、转变'})
|
||
if moon_house == 10:
|
||
triggers.append({'reference': '月亮', 'house': 10, 'area': '公众形象、事业方向'})
|
||
|
||
# 从上升角度
|
||
asc_house = (saturn_idx - asc_idx) % 12 + 1
|
||
if asc_house == 1:
|
||
triggers.append({'reference': '上升', 'house': 1, 'area': '自我、身体、人生方向'})
|
||
if asc_house == 4:
|
||
triggers.append({'reference': '上升', 'house': 4, 'area': '家庭、根基、内在稳定'})
|
||
if asc_house == 8:
|
||
triggers.append({'reference': '上升', 'house': 8, 'area': '危机、变革、共享资源'})
|
||
if asc_house == 10:
|
||
triggers.append({'reference': '上升', 'house': 10, 'area': '事业、社会地位、成就'})
|
||
|
||
return {
|
||
'active': len(triggers) > 0,
|
||
'triggers': triggers,
|
||
'intensity': '高' if len(triggers) >= 3 else '中等' if len(triggers) >= 2 else '低' if triggers else '无',
|
||
'advice': _kantaka_advice(triggers) if triggers else None,
|
||
}
|
||
|
||
|
||
def _kantaka_advice(triggers: List[Dict]) -> str:
|
||
"""生成Kantaka Shani建议"""
|
||
areas = [t['area'] for t in triggers]
|
||
if '自我认同、情感安全' in areas or '自我、身体、人生方向' in areas:
|
||
return '当前阶段需特别关注自我认知和身体健康,避免冲动决策。'
|
||
if '事业、社会地位、成就' in areas or '公众形象、事业方向' in areas:
|
||
return '事业领域面临考验,需稳健行事,避免冒进。长期规划优于短期投机。'
|
||
if '家庭、根基、内在稳定' in areas or '家庭、内在安全感' in areas:
|
||
return '家庭和内在安全感需要额外关注,适合沉淀和积累。'
|
||
if '危机、变革、共享资源' in areas or '深层恐惧、转变' in areas:
|
||
return '面临深层转变,适合心理探索和精神成长,避免财务冒进。'
|
||
return '土星考验期间,保持耐心和纪律是度过难关的关键。'
|
||
|
||
|
||
def calc_sade_sati_complete(moon_degree: float, asc_degree: float,
|
||
transit_saturn_degree: float,
|
||
current_date: datetime = None) -> Dict:
|
||
"""
|
||
完整的Sade Sati + Kantaka Shani综合分析。
|
||
|
||
Args:
|
||
moon_degree: 月亮黄道经度(0-360)
|
||
asc_degree: 上升黄道经度(0-360)
|
||
transit_saturn_degree: 行运土星黄道经度(0-360)
|
||
current_date: 当前日期
|
||
|
||
Returns:
|
||
完整的Sade Sati分析
|
||
"""
|
||
moon_sign = SIGNS[int(moon_degree / 30) % 12]
|
||
asc_sign = SIGNS[int(asc_degree / 30) % 12]
|
||
saturn_sign = SIGNS[int(transit_saturn_degree / 30) % 12]
|
||
|
||
sade_sati = calc_sade_sati(moon_sign, saturn_sign, current_date)
|
||
kantaka = calc_kantaka_shani(moon_sign, asc_sign, saturn_sign)
|
||
|
||
# 综合强度评定
|
||
if sade_sati['active'] and kantaka['active']:
|
||
overall = '极高压力 — Sade Sati与Kantaka Shani同时活跃'
|
||
elif sade_sati['active']:
|
||
overall = f'Sade Sati {sade_sati["intensity"]}压力期间'
|
||
elif kantaka['active']:
|
||
overall = f'仅Kantaka Shani — 土星尖刺压力({kantaka["intensity"]})'
|
||
else:
|
||
overall = '无Sade Sati或Kantaka Shani压力'
|
||
|
||
return {
|
||
'method': 'Sade Sati + Kantaka Shani 完整分析v1.0',
|
||
'moon_sign': moon_sign,
|
||
'asc_sign': asc_sign,
|
||
'saturn_sign': saturn_sign,
|
||
'sade_sati': sade_sati,
|
||
'kantaka_shani': kantaka,
|
||
'overall_assessment': overall,
|
||
}
|