feat: 完整数据版加入六种星座大运

Sthira、Drig、Shoola、Navamsha、Niryaana Shoola 与 K.N. Rao 的 Chara 从本盘算出。Lagna Kendradi 不上表。未推送。
This commit is contained in:
jesse-ux
2026-10-09 00:43:00 +08:00
parent e1c7383c5e
commit 9d3eb23ee4
12 changed files with 4148 additions and 3 deletions
+216
View File
@@ -0,0 +1,216 @@
#!/usr/bin/env python3
"""Goel / BVB short graha and rashi strength for the report rashi dashas.
Dignity, amsha, and kendradi units follow the short method in
Predicting through Jaimini Astrology, printed pages 100-108.
This is not Shadbala and not a house-strength table from another program.
"""
from __future__ import annotations
from typing import Dict, Mapping
try:
from jaimini import FRIENDSHIP, KARAKA_7, SIGNS, calc_chara_karaka_7
from narayana_dasha import SIGN_LORDS, _rasi_aspects
except ImportError: # pragma: no cover - package import
from scripts.jaimini import FRIENDSHIP, KARAKA_7, SIGNS, calc_chara_karaka_7
from scripts.narayana_dasha import SIGN_LORDS, _rasi_aspects
CLASSICAL_PLANETS = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn")
_EXALT_SIGN = {
"Sun": 0,
"Moon": 1,
"Mars": 9,
"Mercury": 5,
"Jupiter": 3,
"Venus": 11,
"Saturn": 6,
}
_DEBIL_SIGN = {
"Sun": 6,
"Moon": 7,
"Mars": 3,
"Mercury": 11,
"Jupiter": 9,
"Venus": 5,
"Saturn": 0,
}
_OWN_SIGNS = {
"Sun": {4},
"Moon": {3},
"Mars": {0, 7},
"Mercury": {2, 5},
"Jupiter": {8, 11},
"Venus": {1, 6},
"Saturn": {9, 10},
}
# Moolatrikona bands are degree-in-sign, end exclusive.
_MOOLATRIKONA = {
"Sun": ((4, 0.0, 20.0),),
"Moon": ((1, 3.0, 30.0),),
"Mars": ((0, 0.0, 12.0),),
"Mercury": ((5, 15.0, 20.0),),
"Jupiter": ((8, 0.0, 10.0),),
"Venus": ((6, 0.0, 15.0),),
"Saturn": ((10, 0.0, 20.0),),
}
_AMSHA_BY_RANK = {1: 70, 2: 60, 3: 50, 4: 40, 5: 30, 6: 20, 7: 10}
_CHARA_RASHI = {0: 20, 1: 40, 2: 60}
def sign_index(longitude: float) -> int:
return int(float(longitude) % 360.0 // 30.0)
def degree_in_sign(longitude: float) -> float:
return float(longitude) % 30.0
def _in_moolatrikona(planet: str, sign_idx: int, degree: float) -> bool:
for sign, start, end in _MOOLATRIKONA.get(planet, ()):
if sign == sign_idx and start <= degree < end:
return True
return False
def dignity_units(planet: str, sign_idx: int, degree: float) -> int:
"""Short-method dignity. Whole exalt and debil signs, except Mercury in Virgo."""
if planet not in CLASSICAL_PLANETS:
raise KeyError(planet)
if sign_idx == _DEBIL_SIGN[planet]:
return 10
if planet == "Mercury" and sign_idx == 5:
if degree < 15.0:
return 70
if degree < 20.0:
return 60
return 50
if sign_idx == _EXALT_SIGN[planet]:
return 70
if _in_moolatrikona(planet, sign_idx, degree):
return 60
if sign_idx in _OWN_SIGNS[planet]:
return 50
lord = SIGN_LORDS[SIGNS[sign_idx]]
relation = FRIENDSHIP.get(planet, {})
if lord in relation.get("friend", []):
return 40
if lord in relation.get("enemy", []):
return 20
return 30
def kendradi_units(sign_idx: int, ak_sign_idx: int) -> int:
offset = (int(sign_idx) - int(ak_sign_idx)) % 12
if offset % 3 == 0:
return 60
if offset % 3 == 1:
return 40
return 20
def karaka_ranks(planet_lons: Mapping[str, float]) -> Dict[str, int]:
degrees = {
planet: degree_in_sign(planet_lons[planet])
for planet in CLASSICAL_PLANETS
if planet in planet_lons
}
table = calc_chara_karaka_7(degrees).get("karaka_table") or {}
ranks = {}
for rank, name in KARAKA_7.items():
row = table.get(name) or {}
planet = row.get("planet")
if planet:
ranks[str(planet)] = int(rank)
return ranks
def graha_strengths(planet_lons: Mapping[str, float]) -> Dict[str, Dict[str, int]]:
ranks = karaka_ranks(planet_lons)
if "Mars" not in ranks and len(ranks) < 7:
raise ValueError("short graha strength needs the seven classical planets")
ak_planet = next(planet for planet, rank in ranks.items() if rank == 1)
ak_sign = sign_index(planet_lons[ak_planet])
rows = {}
for planet in CLASSICAL_PLANETS:
sign_idx = sign_index(planet_lons[planet])
degree = degree_in_sign(planet_lons[planet])
dignity = dignity_units(planet, sign_idx, degree)
amsha = _AMSHA_BY_RANK[ranks[planet]]
kendradi = kendradi_units(sign_idx, ak_sign)
rows[planet] = {
"sign_index": sign_idx,
"dignity": dignity,
"amsha": amsha,
"kendradi": kendradi,
"total": dignity + amsha + kendradi,
"karaka_rank": ranks[planet],
}
return rows
def _aspect_units(sign_idx: int, planet_lons: Mapping[str, float], lord: str) -> int:
contributors = []
for planet in (lord, "Jupiter", "Mercury"):
if planet not in contributors:
contributors.append(planet)
total = 0
for planet in contributors:
if planet not in planet_lons:
continue
source = sign_index(planet_lons[planet])
if source != sign_idx and not _rasi_aspects(source, sign_idx):
continue
if planet == lord and planet in {"Jupiter", "Mercury"}:
total += 120
else:
total += 60
return total
def rashi_strengths(
planet_lons: Mapping[str, float],
graha: Mapping[str, Mapping[str, int]] | None = None,
) -> Dict[int, Dict[str, int]]:
graha_rows = graha if graha is not None else graha_strengths(planet_lons)
occupants: Dict[int, int] = {index: 0 for index in range(12)}
for planet in CLASSICAL_PLANETS:
occupants[sign_index(planet_lons[planet])] += 1
rows = {}
for sign_idx in range(12):
lord = SIGN_LORDS[SIGNS[sign_idx]]
chara = _CHARA_RASHI[sign_idx % 3]
sthira = 10 * occupants[sign_idx]
aspect = _aspect_units(sign_idx, planet_lons, lord)
lord_graha = int(graha_rows[lord]["total"])
rows[sign_idx] = {
"chara": chara,
"sthira": sthira,
"aspect": aspect,
"lord_graha": lord_graha,
"total": chara + sthira + aspect + lord_graha,
}
return rows
def stronger_sign(left: int, right: int, strengths: Mapping[int, Mapping[str, int]], planet_lons: Mapping[str, float]) -> Dict[str, object]:
"""Pick the stronger sign. Equal totals use the higher chara-karaka lord."""
left_total = int(strengths[left]["total"])
right_total = int(strengths[right]["total"])
if left_total != right_total:
selected = left if left_total > right_total else right
return {"status": "selected", "sign_index": selected, "tie_break": "rashi_total"}
ranks = karaka_ranks(planet_lons)
left_lord = SIGN_LORDS[SIGNS[left]]
right_lord = SIGN_LORDS[SIGNS[right]]
left_rank = ranks.get(left_lord)
right_rank = ranks.get(right_lord)
if left_rank is None or right_rank is None or left_rank == right_rank:
return {
"status": "unresolved",
"reason": "equal_rashi_strength_and_karaka",
"candidates": (left, right),
}
selected = left if left_rank < right_rank else right
return {"status": "selected", "sign_index": selected, "tie_break": "lord_karaka_rank"}
+20 -2
View File
@@ -2185,6 +2185,18 @@ def _apply_local_report_conventions(markdown: str, *, language: str = "zh") -> s
return text
def _append_native_rashi_dashas(markdown: str, packet: dict) -> str:
"""Full-data edition only. The parity reader and the consult card do not call this."""
try:
from rashi_dasha_report import render_rashi_dasha_chapter
except ModuleNotFoundError: # pragma: no cover - package import compatibility
from scripts.rashi_dasha_report import render_rashi_dasha_chapter
chapter = render_rashi_dasha_chapter(packet if isinstance(packet, dict) else {})
if not chapter.strip():
return markdown
return markdown.rstrip() + "\n\n" + chapter
def _insert_opening_focus(markdown: str, focus: str) -> str:
"""Put the current-period focus directly under the title."""
first = next((line.strip() for line in focus.splitlines() if line.strip()), "")
@@ -2221,7 +2233,10 @@ def render_full_data_markdown(packet: dict) -> str:
count=1,
flags=re.DOTALL,
).strip()
english_markdown = _insert_opening_focus(render_pl9_parity_markdown(packet), focus)
english_markdown = _append_native_rashi_dashas(
_insert_opening_focus(render_pl9_parity_markdown(packet), focus),
packet,
)
if structured_appendix:
english_markdown = (
english_markdown.rstrip()
@@ -2246,7 +2261,10 @@ def render_full_data_markdown(packet: dict) -> str:
count=1,
flags=re.DOTALL,
).strip()
zh_markdown = _insert_opening_focus(render_pl9_parity_markdown(packet), focus)
zh_markdown = _append_native_rashi_dashas(
_insert_opening_focus(render_pl9_parity_markdown(packet), focus),
packet,
)
if zh_structured_appendix and "## 结构化资料附录" not in zh_markdown:
zh_markdown = (
zh_markdown.rstrip()
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""Markdown chapter for the seven report-only rashi dashas."""
from __future__ import annotations
from typing import Mapping
try:
from rashi_dashas_native import calculate_rashi_dashas
except ImportError: # pragma: no cover - package import
from scripts.rashi_dashas_native import calculate_rashi_dashas
_SIGN_CN = {
"Aries": "白羊座",
"Taurus": "金牛座",
"Gemini": "双子座",
"Cancer": "巨蟹座",
"Leo": "狮子座",
"Virgo": "处女座",
"Libra": "天秤座",
"Scorpio": "天蝎座",
"Sagittarius": "射手座",
"Capricorn": "摩羯座",
"Aquarius": "水瓶座",
"Pisces": "双鱼座",
}
# Chinese headings avoid the token Dasha. The report cleaner turns that token
# into 大运 and then deletes the linebreak before the next Chinese sentence.
_ORDER = (
("sthira", "Sthira", "Sthira Dasha"),
("niryaana_shoola", "Niryaana Shoola", "Niryaana Shoola Dasha"),
("drig", "Drig", "Drig Dasha"),
("navamsha", "Navamsha", "Navamsha Dasha"),
("shoola", "Shoola", "Shoola Dasha"),
("chara", "Chara", "Chara Dasha"),
("lagna_kendradi", "Lagna Kendradi", "Lagna Kendradi Rashi Dasha"),
)
_CLASSICAL = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn")
def _language(packet: Mapping) -> str:
raw = packet.get("report_language") or packet.get("language") or "zh"
return "en" if str(raw).lower().startswith("en") else "zh"
def _longitude(row: Mapping) -> float | None:
# Ascendant "degree" is the degree inside the sign. Full longitude is degree_raw / lon.
for key in ("degree_raw", "lon", "longitude"):
value = row.get(key)
if isinstance(value, (int, float)):
return float(value)
sign = row.get("sign")
degree = row.get("degree_in_sign")
if sign in _SIGN_CN and isinstance(degree, (int, float)):
signs = list(_SIGN_CN)
return signs.index(str(sign)) * 30.0 + float(degree)
return None
def _birth_date(packet: Mapping) -> str | None:
birth = packet.get("birth_info")
if not isinstance(birth, Mapping):
return None
if birth.get("date"):
return str(birth.get("date"))
year, month, day = birth.get("year"), birth.get("month"), birth.get("day")
if all(isinstance(part, int) for part in (year, month, day)):
return f"{int(year):04d}-{int(month):02d}-{int(day):02d}"
return None
def chart_inputs(packet: Mapping) -> dict | None:
core = packet.get("core_chart")
if not isinstance(core, Mapping):
return None
asc = core.get("ascendant")
planets = core.get("planets")
if not isinstance(asc, Mapping) or not isinstance(planets, Mapping):
return None
asc_lon = _longitude(asc)
birth = _birth_date(packet)
if asc_lon is None or not birth:
return None
longitudes = {}
for name in list(_CLASSICAL) + ["Rahu", "Ketu"]:
row = planets.get(name)
if isinstance(row, Mapping):
value = _longitude(row)
if value is not None:
longitudes[name] = value
if any(name not in longitudes for name in _CLASSICAL):
return None
return {"birth": birth, "ascendant_longitude": asc_lon, "planet_lons": longitudes}
def _sign_label(sign: str, language: str) -> str:
if language == "zh":
return _SIGN_CN.get(sign, sign)
return sign
def _method_line(section: Mapping, language: str) -> str:
if section.get("status") != "listed":
return str(section.get("reason_en") if language == "en" else section.get("reason_zh") or "")
profile = section.get("profile_id") or ""
if language == "en":
return (
f"Method used on this site: {profile}, source: {section.get('source_en')}; "
"not checked one-by-one against external software."
)
return f"本站选用的方法:{profile},出处:{section.get('source_zh')};未与外部软件逐一核对。"
def render_rashi_dasha_chapter(packet: Mapping) -> str:
"""Return the chapter, or an empty string when the packet has no longitudes."""
inputs = chart_inputs(packet)
if inputs is None:
return ""
language = _language(packet)
sections = calculate_rashi_dashas(inputs["birth"], inputs["ascendant_longitude"], inputs["planet_lons"])
if language == "en":
lines = [
"## Sign dashas calculated on this site",
"",
"These tables list signs and dates only.",
"",
]
sign_header = "| Index | Sign | Start | End | Years |"
child_header = "| Index | Maha | Antara | Start | End |"
else:
lines = [
"## 星座大运(本站计算)",
"",
"以下只列出星座和日期。",
"",
]
sign_header = "| 序号 | 星座 | 起始 | 结束 | 年数 |"
child_header = "| 序号 | 主运 | 子运 | 起始 | 结束 |"
for key, title_zh, title_en in _ORDER:
section = sections[key]
title = title_en if language == "en" else title_zh
lines.extend([f"### {title}", "", _method_line(section, language), ""])
if key == "chara" and section.get("status") == "listed":
lines.extend([
"This section is the K.N. Rao Chara table. It is not the Narayana table."
if language == "en"
else "这一节是 K.N. Rao 的 Chara 表,不是 Narayana 表。",
"",
])
periods = section.get("periods") or []
if not periods:
continue
lines.extend([sign_header, "|------|------|------|------|------|"])
child_rows = []
for index, period in enumerate(periods, start=1):
label = _sign_label(str(period.get("sign")), language)
lines.append(
f"| {index} | {label} | {period.get('start')} | {period.get('end')} | {period.get('years')} |"
)
for child in period.get("antardasha") or []:
child_rows.append((label, _sign_label(str(child.get("sign")), language), child.get("start"), child.get("end")))
lines.append("")
if child_rows:
lines.extend([child_header, "|------|------|------|------|------|"])
for index, (parent, child, start, end) in enumerate(child_rows, start=1):
lines.append(f"| {index} | {parent} | {child} | {start} | {end} |")
lines.append("")
return "\n".join(lines).rstrip() + "\n"
+417
View File
@@ -0,0 +1,417 @@
#!/usr/bin/env python3
"""Report-only rashi dashas computed from this site's longitudes.
The seven sections are Sthira, Drig, Shoola, Navamsha, Niryaana Shoola,
Lagna Kendradi, and Chara. Lagna Kendradi has no safe default and stays off
the table. Chara dates the existing kn_rao rows; it does not recalculate them.
"""
from __future__ import annotations
import calendar
from datetime import date, datetime, timedelta
from typing import Dict, Mapping, Sequence
try:
from goel_short_strength import (
CLASSICAL_PLANETS,
degree_in_sign,
graha_strengths,
rashi_strengths,
sign_index,
stronger_sign,
)
from jaimini import SIGNS, calc_chara_dasha_with_antardasha
from narayana_dasha import SIGN_LORDS, calculate_narayana_period_years
except ImportError: # pragma: no cover - package import
from scripts.goel_short_strength import (
CLASSICAL_PLANETS,
degree_in_sign,
graha_strengths,
rashi_strengths,
sign_index,
stronger_sign,
)
from scripts.jaimini import SIGNS, calc_chara_dasha_with_antardasha
from scripts.narayana_dasha import SIGN_LORDS, calculate_narayana_period_years
_MOVABLE = {0, 3, 6, 9}
_FIXED = {1, 4, 7, 10}
_DUAL = {2, 5, 8, 11}
def _as_date(value: date | datetime | str) -> date:
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
text = str(value).strip()
if "T" in text:
text = text.split("T", 1)[0]
if " " in text:
text = text.split(" ", 1)[0]
year, month, day = (int(part) for part in text[:10].split("-"))
return date(year, month, day)
def add_years(day: date, years: int) -> date:
try:
return day.replace(year=day.year + int(years))
except ValueError:
return day.replace(year=day.year + int(years), day=28)
def add_months(day: date, months: int) -> date:
month_index = day.month - 1 + int(months)
year = day.year + month_index // 12
month = month_index % 12 + 1
last = calendar.monthrange(year, month)[1]
return date(year, month, min(day.day, last))
def _iso(day: date) -> str:
return day.isoformat()
def _forward(sign_idx: int, count: int = 12) -> list[int]:
return [(int(sign_idx) + offset) % 12 for offset in range(count)]
def _reverse(sign_idx: int, count: int = 12) -> list[int]:
return [(int(sign_idx) - offset) % 12 for offset in range(count)]
def _aspecting_signs(sign_idx: int) -> set[int]:
sign_idx = int(sign_idx) % 12
if sign_idx in _MOVABLE:
return {candidate for candidate in _FIXED if candidate != (sign_idx + 1) % 12}
if sign_idx in _FIXED:
return {candidate for candidate in _MOVABLE if candidate != (sign_idx - 1) % 12}
return {candidate for candidate in _DUAL if candidate != sign_idx}
def drig_aspect_sequence(lagna_sign: int) -> list[int]:
"""Ninth, tenth, and eleventh anchors, each followed by the signs it aspects.
Crux printed page 395: an even-footed ninth is followed in reverse.
Zero-based even indexes are the odd zodiac signs, so they go forward.
"""
rows: list[int] = []
for offset in (8, 9, 10):
anchor = (int(lagna_sign) + offset) % 12
scan = _forward(anchor) if anchor % 2 == 0 else _reverse(anchor)
allowed = _aspecting_signs(anchor)
rows.extend([anchor] + [sign for sign in scan[1:] if sign in allowed])
return rows[:12]
def navamsha_sign(longitude: float) -> int:
longitude = float(longitude) % 360.0
rashi = int(longitude // 30.0)
pada = int((longitude % 30.0) / (10.0 / 3.0))
if pada > 8:
pada = 8
start = (0, 9, 6, 3)[rashi % 4]
return (start + pada) % 12
def navamsha_years(sign_idx: int, lord_sign_idx: int) -> int:
"""Odd signs count forward, even signs backward. No dual lords and no minus one."""
if int(sign_idx) % 2 == 0:
distance = (int(lord_sign_idx) - int(sign_idx)) % 12
else:
distance = (int(sign_idx) - int(lord_sign_idx)) % 12
if distance == 0:
return 12
return distance + 1
def sthira_years(sign_idx: int) -> int:
return (7, 8, 9)[int(sign_idx) % 3]
def _period(sign_idx: int, years: int, start: date, end: date, children: list[dict] | None = None) -> dict:
row = {
"sign": SIGNS[int(sign_idx) % 12],
"sign_index": int(sign_idx) % 12,
"years": int(years),
"start": _iso(start),
"end": _iso(end),
}
if children is not None:
row["antardasha"] = children
return row
def _calendar_children(parent_sign: int, start: date, *, months_each: int, count: int = 12) -> list[dict]:
rows = []
cursor = start
for offset in range(count):
child = (int(parent_sign) + offset) % 12
nxt = add_months(cursor, months_each)
rows.append({
"sign": SIGNS[child],
"sign_index": child,
"start": _iso(cursor),
"end": _iso(nxt),
})
cursor = nxt
return rows
def _equal_children(order: Sequence[int], start: date, end: date) -> list[dict]:
if not order:
return []
total = (datetime.combine(end, datetime.min.time()) - datetime.combine(start, datetime.min.time())).total_seconds()
if total <= 0:
return []
origin = datetime.combine(start, datetime.min.time())
finish = datetime.combine(end, datetime.min.time())
rows = []
for offset, sign_idx in enumerate(order):
child_end = finish if offset == len(order) - 1 else origin + timedelta(seconds=total * (offset + 1) / len(order))
child_start = origin if offset == 0 else origin + timedelta(seconds=total * offset / len(order))
rows.append({
"sign": SIGNS[int(sign_idx) % 12],
"sign_index": int(sign_idx) % 12,
"start": child_start.date().isoformat(),
"end": child_end.date().isoformat(),
})
return rows
def _require_planets(planet_lons: Mapping[str, float]) -> None:
missing = [planet for planet in CLASSICAL_PLANETS if planet not in planet_lons]
if missing:
raise ValueError("missing planets: " + ", ".join(missing))
def select_brahma(lagna_sign: int, planet_lons: Mapping[str, float], graha: Mapping, rashi: Mapping) -> dict:
seventh = (int(lagna_sign) + 6) % 12
picked = stronger_sign(lagna_sign, seventh, rashi, planet_lons)
if picked.get("status") != "selected":
return {"status": "unresolved", "reason": picked.get("reason") or "lagna_seventh_unresolved", "step": "start_sign"}
anchor = int(picked["sign_index"])
candidates = []
for house in (6, 8, 12):
sign_idx = (anchor + house - 1) % 12
lord = SIGN_LORDS[SIGNS[sign_idx]]
if lord == "Saturn":
continue
candidates.append((
int(graha[lord]["total"]),
degree_in_sign(planet_lons[lord]),
lord,
sign_index(planet_lons[lord]),
))
if not candidates:
return {"status": "unresolved", "reason": "no_brahma_lord_after_excluding_saturn", "step": "start_sign"}
candidates.sort(key=lambda row: (row[0], row[1]), reverse=True)
best = candidates[0]
tied = [row for row in candidates if row[0] == best[0] and row[1] == best[1] and row[2] != best[2]]
if tied:
return {"status": "unresolved", "reason": "brahma_lord_tie", "step": "start_sign"}
return {
"status": "selected",
"planet": best[2],
"sign_index": best[3],
"anchor_sign_index": anchor,
"tie_break": picked.get("tie_break"),
}
def _listed(profile_id: str, source_zh: str, source_en: str, periods: list[dict], **extra) -> dict:
row = {
"profile_id": profile_id,
"source_zh": source_zh,
"source_en": source_en,
"status": "listed",
"periods": periods,
}
row.update(extra)
return row
def _omitted(profile_id: str, source_zh: str, source_en: str, reason_zh: str, reason_en: str) -> dict:
return {
"profile_id": profile_id,
"source_zh": source_zh,
"source_en": source_en,
"status": "omitted",
"reason_zh": reason_zh,
"reason_en": reason_en,
"periods": [],
}
def _sthira(birth: date, lagna: int, planet_lons: Mapping[str, float], graha: Mapping, rashi: Mapping) -> dict:
source_zh = "Predicting through Jaimini Astrology,印页 100–108"
source_en = "Predicting through Jaimini Astrology, printed pages 100-108"
profile = "goel_rao_short_brahma_method_v1"
picked = select_brahma(lagna, planet_lons, graha, rashi)
if picked.get("status") != "selected":
return _omitted(profile, source_zh, source_en, "本轮不上表:Brahma 星座没有选出。", "This report does not include Sthira: the Brahma sign was not selected.")
start_sign = int(picked["sign_index"])
periods = []
cursor = birth
for sign_idx in _forward(start_sign):
years = sthira_years(sign_idx)
end = add_years(cursor, years)
children = _calendar_children(sign_idx, cursor, months_each=years)
periods.append(_period(sign_idx, years, cursor, end, children))
cursor = end
return _listed(profile, source_zh, source_en, periods, brahma_planet=picked["planet"], direction="direct")
def _narayana_years(sign_idx: int, planet_lons: Mapping[str, float]) -> int:
lord = SIGN_LORDS[SIGNS[sign_idx]]
lord_sign = sign_index(planet_lons[lord])
years, _adjustment = calculate_narayana_period_years(
sign_idx,
lord,
lord_sign,
profile="jaimini_odd_footed_v1",
)
return int(years)
def _drig(birth: date, lagna: int, planet_lons: Mapping[str, float], graha: Mapping) -> dict:
source_zh = "Crux of Vedic Astrology,印页 288、395"
source_en = "Crux of Vedic Astrology, printed pages 288 and 395"
profile = "drig_rath_aspect_sequence_v1"
periods = []
cursor = birth
for sign_idx in drig_aspect_sequence(lagna):
years = _narayana_years(sign_idx, planet_lons)
end = add_years(cursor, years)
children = _drig_children(sign_idx, cursor, end, graha, planet_lons)
periods.append(_period(sign_idx, years, cursor, end, children))
cursor = end
return _listed(profile, source_zh, source_en, periods, direction="aspect_groups")
def _drig_children(md_sign: int, start: date, end: date, graha: Mapping, planet_lons: Mapping[str, float]) -> list[dict]:
lord = SIGN_LORDS[SIGNS[md_sign]]
seventh_lord = SIGN_LORDS[SIGNS[(md_sign + 6) % 12]]
left = int(graha[lord]["total"])
right = int(graha[seventh_lord]["total"])
if left == right:
return []
chosen = lord if left > right else seventh_lord
seed = sign_index(planet_lons[chosen])
order = _forward(seed) if md_sign % 2 == 0 else _reverse(seed)
return _equal_children(order, start, end)
def _nine_year(birth: date, lagna: int, planet_lons: Mapping[str, float], rashi: Mapping, profile: str, source_zh: str, source_en: str) -> dict:
seventh = (lagna + 6) % 12
picked = stronger_sign(lagna, seventh, rashi, planet_lons)
if picked.get("status") != "selected":
return _omitted(
profile,
source_zh,
source_en,
"本轮不上表:上升与第七宫的短法力量没有分出强弱。",
"This report does not include this dasha: Lagna and the seventh are equal in the short-method strength.",
)
start_sign = int(picked["sign_index"])
periods = []
cursor = birth
for sign_idx in _forward(start_sign):
end = add_years(cursor, 9)
children = _calendar_children(sign_idx, cursor, months_each=9)
periods.append(_period(sign_idx, 9, cursor, end, children))
cursor = end
return _listed(profile, source_zh, source_en, periods, direction="direct", start_tie_break=picked.get("tie_break"))
def _navamsha(birth: date, asc_lon: float, planet_lons: Mapping[str, float]) -> dict:
source_zh = "Predicting through Jaimini Astrology,印页 139"
source_en = "Predicting through Jaimini Astrology, printed page 139"
profile = "goel_method_1_v1"
d9_lagna = navamsha_sign(asc_lon)
d9_planets = {planet: navamsha_sign(planet_lons[planet]) for planet in CLASSICAL_PLANETS}
start_sign = d9_lagna if d9_lagna % 2 == 0 else (d9_lagna + 6) % 12
periods = []
cursor = birth
for sign_idx in _forward(start_sign):
lord = SIGN_LORDS[SIGNS[sign_idx]]
years = navamsha_years(sign_idx, d9_planets[lord])
end = add_years(cursor, years)
children = _calendar_children(sign_idx, cursor, months_each=years)
periods.append(_period(sign_idx, years, cursor, end, children))
cursor = end
return _listed(
profile,
source_zh,
source_en,
periods,
direction="direct",
d9_lagna_index=d9_lagna,
)
def _chara(birth: date, lagna: int, planet_lons: Mapping[str, float]) -> dict:
source_zh = "Predicting through Jaimini's Chara Dasha,印页 32–36"
source_en = "Predicting through Jaimini's Chara Dasha, printed pages 32-36"
profile = "kn_rao"
longitudes = {planet: float(value) for planet, value in planet_lons.items()}
calculated = calc_chara_dasha_with_antardasha(
lagna,
longitudes,
birth.year,
birth.month,
birth.day,
profile="kn_rao",
)
periods = []
cursor = birth
for row in calculated.get("dasha_sequence") or []:
years = int(row["duration_years"])
end = add_years(cursor, years)
order = [int(child["sign_idx"]) for child in row.get("antardashas") or []]
children = _equal_children(order, cursor, end)
periods.append(_period(int(row["sign_idx"]), years, cursor, end, children))
cursor = end
return _listed(profile, source_zh, source_en, periods, direction="ninth_house_even_footed")
def calculate_rashi_dashas(birth: date | datetime | str, ascendant_longitude: float, planet_lons: Mapping[str, float]) -> Dict[str, dict]:
"""Return the seven report sections. Strength and seeds come from these longitudes."""
_require_planets(planet_lons)
birth_day = _as_date(birth)
longitudes = {planet: float(planet_lons[planet]) for planet in planet_lons}
lagna = sign_index(float(ascendant_longitude))
graha = graha_strengths(longitudes)
rashi = rashi_strengths(longitudes, graha)
return {
"sthira": _sthira(birth_day, lagna, longitudes, graha, rashi),
"drig": _drig(birth_day, lagna, longitudes, graha),
"shoola": _nine_year(
birth_day,
lagna,
longitudes,
rashi,
"shoola_forward_md_direct_ad_v1",
"Crux of Vedic Astrology,印页 395",
"Crux of Vedic Astrology, printed page 395",
),
"navamsha": _navamsha(birth_day, float(ascendant_longitude), longitudes),
"niryaana_shoola": _nine_year(
birth_day,
lagna,
longitudes,
rashi,
"goel_nirayana_shoola_v1",
"Predicting through Jaimini Astrology,印页 162",
"Predicting through Jaimini Astrology, printed page 162",
),
"lagna_kendradi": _omitted(
"",
"",
"",
"本轮不上表:没有选定的安全默认算法,故本报告不列出该大运。",
"This report does not include Lagna Kendradi: no safe default method is selected.",
),
"chara": _chara(birth_day, lagna, longitudes),
}
@@ -0,0 +1,196 @@
#!/usr/bin/env python3
"""Freeze PyJHora 4.8.7 rashi-dasha rows for four public or fictional charts.
Run with the Anaconda interpreter that has PyJHora 4.8.7. Product code does
not import this module. Tests read the JSON only.
"""
from __future__ import annotations
import importlib
import importlib.metadata
import io
import json
import sys
from contextlib import redirect_stdout
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path[:0] = [str(ROOT), str(ROOT / "scripts")]
OUT = ROOT / "tests" / "fixtures" / "rashi_dasha_pyjhora_20261008.json"
CASE_IDS = (
"steve_jobs_1955_aa",
"barack_obama_1961_aa",
"einstein_1879_aa",
"fictional_reader_main",
)
def _births() -> dict:
public = json.loads((ROOT / "references" / "public_oracle_cases.json").read_text(encoding="utf-8"))
births = {case["id"]: case["birth"] for case in public["cases"]}
special = json.loads((ROOT / "tests" / "fixtures" / "special_lagnas_pyjhora_487.json").read_text(encoding="utf-8"))
births["fictional_reader_main"] = special["cases"]["fictional_reader_main"]["birth"]
return births
def _parse_row(row):
if not isinstance(row, (list, tuple)) or len(row) < 3:
return None
if (
len(row) >= 4
and isinstance(row[0], int)
and isinstance(row[1], int)
and not isinstance(row[2], (int, float))
):
signs = [int(row[0]), int(row[1])]
start, years = row[2], row[3]
else:
lord, start, years = row[0], row[1], row[2]
signs = [int(item) for item in lord] if isinstance(lord, (list, tuple)) else [int(lord)]
if isinstance(start, (list, tuple)):
start_iso = f"{int(start[0]):04d}-{int(start[1]):02d}-{int(start[2]):02d}"
else:
start_iso = str(start)[:10]
years_n = float(years)
years_out = int(round(years_n)) if abs(years_n - round(years_n)) < 1e-6 else years_n
return signs, start_iso, years_out
def _call(fn, args):
with redirect_stdout(io.StringIO()):
return fn(*args)
def _pyjhora_case(birth: dict, modules, swe, drik, charts, Place) -> dict:
local_hour = birth["hour"] + birth["minute"] / 60.0 + birth.get("second", 0) / 3600.0
with redirect_stdout(io.StringIO()):
drik.set_ayanamsa_mode("LAHIRI")
jd = swe.julday(birth["year"], birth["month"], birth["day"], local_hour)
place = Place("birth_place", birth["lat"], birth["lon"], birth["tz"])
positions = charts.rasi_chart(jd, place)
dob = (birth["year"], birth["month"], birth["day"])
tob = (birth["hour"], birth["minute"], birth["second"])
calls = {
"sthira": lambda level: modules["sthira"].get_dhasa_antardhasa(dob, tob, place, dhasa_level_index=level),
"shoola": lambda level: modules["shoola"].shoola_dhasa(positions, dob, tob, dhasa_level_index=level),
"niryaana_shoola": lambda level: modules["nirayana"].nirayana_shoola_dhasa(positions, dob, tob, dhasa_level_index=level),
"drig": lambda level: modules["drig"].drig_dhasa(positions, dob, tob, dhasa_level_index=level),
"navamsha": lambda level: modules["navamsa"].get_dhasa_antardhasa(dob, tob, place, dhasa_level_index=level),
"chara": lambda level: modules["chara"].get_dhasa_antardhasa(dob, tob, place, dhasa_level_index=level),
"lagna_kendradi": lambda level: modules["lagna_kendraadhi"].lagna_kendradhi_rasi_dhasa(dob, tob, place, dhasa_level_index=level),
}
parsed = {}
for key, call in calls.items():
level_1 = [_parse_row(row) for row in _call(call, (1,))]
level_2 = [_parse_row(row) for row in _call(call, (2,))]
majors = [row for row in level_1 if row and len(row[0]) == 1]
if not majors:
majors = [row for row in level_2 if row and len(row[0]) == 1]
subs = [row for row in level_2 if row and len(row[0]) == 2]
first_sign = majors[0][0][0] if majors else None
first_subs = [row[0][1] for row in subs if row[0][0] == first_sign]
parsed[key] = {
"sign_indexes": [row[0][0] for row in majors],
"years": [row[2] for row in majors],
"second_start": majors[1][1] if len(majors) > 1 else None,
"first_sub_sign_indexes": first_subs,
}
return parsed
def _succession(signs: list[int]) -> tuple[int, ...]:
return tuple((signs[index] - signs[index - 1]) % 12 for index in range(1, len(signs)))
def _diff_steps(section: dict, pyjhora: dict) -> list[str]:
our_signs = [row["sign_index"] for row in section["periods"]]
their_signs = pyjhora["sign_indexes"]
our_years = [row["years"] for row in section["periods"]]
their_years = pyjhora["years"]
steps = []
if not our_signs or not their_signs or our_signs[0] != their_signs[0]:
steps.append("start_sign")
if _succession(our_signs) != _succession(their_signs):
steps.append("direction")
if sorted(zip(our_signs, our_years)) != sorted(zip(their_signs, their_years)):
steps.append("years")
our_sub = [row["sign_index"] for row in (section["periods"][0].get("antardasha") or [])] if section["periods"] else []
if our_sub != (pyjhora.get("first_sub_sign_indexes") or []):
steps.append("subperiod")
if our_signs == their_signs and our_years == their_years and len(section["periods"]) > 1:
if section["periods"][1]["start"] != pyjhora.get("second_start"):
steps.append("date_basis")
return steps
def main() -> None:
with redirect_stdout(io.StringIO()):
import swisseph as swe
from jhora.horoscope.chart import charts
from jhora.panchanga import drik
from jhora.panchanga.drik import Place
modules = {
name: importlib.import_module("jhora.horoscope.dhasa.raasi." + module_name)
for name, module_name in (
("sthira", "sthira"),
("shoola", "shoola"),
("nirayana", "nirayana"),
("drig", "drig"),
("navamsa", "navamsa"),
("chara", "chara"),
("lagna_kendraadhi", "lagna_kendraadhi"),
)
}
from jyotish_engine import compute_chart_data
from rashi_dashas_native import calculate_rashi_dashas
payload = {
"pyjhora_version": importlib.metadata.version("PyJHora"),
"generator_python_has_pyjhora": True,
"generator": "scripts/research/freeze_rashi_dasha_pyjhora_fixture.py",
"note": "Anaconda PyJHora 4.8.7 produced the external rows. Pytest reads this file and does not import PyJHora.",
"cases": {},
}
births = _births()
for case_id in CASE_IDS:
birth = births[case_id]
chart, _asc, _jd, _ayanamsa = compute_chart_data(
birth["year"], birth["month"], birth["day"], birth["hour"], birth["minute"],
birth["lat"], birth["lon"], birth["tz"], second=birth.get("second") or 0,
)
longitudes = {
name: float(row["degree_raw"])
for name, row in chart["planets"].items()
if isinstance(row, dict) and "degree_raw" in row
}
native = calculate_rashi_dashas(chart["birth_info"]["date"], float(chart["ascendant"]["degree_raw"]), longitudes)
external = _pyjhora_case(birth, modules, swe, drik, charts, Place)
native_view = {}
diff_steps = {}
for key, section in native.items():
if key == "lagna_kendradi":
diff_steps[key] = ["start_sign", "direction", "years", "subperiod"]
native_view[key] = {"status": "omitted", "sign_indexes": [], "years": []}
continue
native_view[key] = {
"sign_indexes": [row["sign_index"] for row in section["periods"]],
"years": [row["years"] for row in section["periods"]],
}
diff_steps[key] = _diff_steps(section, external[key])
payload["cases"][case_id] = {
"birth_source": birth,
"pyjhora": external,
"native": native_view,
"diff_steps": diff_steps,
}
print(case_id, {key: value for key, value in diff_steps.items()}, flush=True)
OUT.parent.mkdir(parents=True, exist_ok=True)
OUT.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(OUT)
if __name__ == "__main__":
main()