完整数据版补上研究口径的推运、次小运和细分盘(#92)。
研究章在渲染时计算,不写入资料包。四种星座大运次小运和 Kalachakra 第三层不改原来的主运、子运。D81、D108、D144 只留在研究章。 自测:test_research_timing、星座大运、域读取、卫生预算、真恒星、jhora 扫描、API 增长、六章标题、英文版。三张虚构盘中英出稿都低于 180 秒。PyJHora 已导入但未做逐层对照。 Co-Authored-By: Grok (agent loop)
This commit is contained in:
co-authored by
Grok (agent loop)
parent
85e8adddbc
commit
d45e65a9ec
@@ -1866,6 +1866,9 @@ def _render_pl9_ai_density_raw_data_markdown_en(packet: dict, language: str = "e
|
||||
if isinstance(varga, dict):
|
||||
lines.extend(["## Divisional Charts Numeric Tables", ""])
|
||||
for chart_name, chart_payload in varga.items():
|
||||
# D81 / D108 / D144 stay in the research chapter only.
|
||||
if any(token in str(chart_name).upper() for token in ("D81", "D108", "D144")):
|
||||
continue
|
||||
positions = chart_payload.get("positions") if isinstance(chart_payload, dict) else None
|
||||
if not isinstance(positions, dict):
|
||||
positions = chart_payload if isinstance(chart_payload, dict) else None
|
||||
@@ -2377,6 +2380,17 @@ def _append_parity_chapters(markdown: str, packet: dict, language: str) -> str:
|
||||
return markdown.rstrip() + "\n\n" + extra + "\n"
|
||||
|
||||
|
||||
def _append_research_chapters(markdown: str, packet: dict, language: str) -> str:
|
||||
try:
|
||||
from scripts.research_timing.render import render_research_chapters
|
||||
except ModuleNotFoundError: # pragma: no cover
|
||||
from research_timing.render import render_research_chapters
|
||||
extra = render_research_chapters(packet, language).strip()
|
||||
if not extra:
|
||||
return markdown
|
||||
return markdown.rstrip() + "\n\n" + extra + "\n"
|
||||
|
||||
|
||||
def render_full_data_markdown(packet: dict) -> str:
|
||||
"""Upstream `pl9_ai_density` branch at 23be1807, on this site's parity body."""
|
||||
if _pl9_report_language(packet if isinstance(packet, dict) else {}) != "en":
|
||||
@@ -2417,6 +2431,7 @@ def render_full_data_markdown(packet: dict) -> str:
|
||||
_pl9_english_report_title(packet),
|
||||
)
|
||||
rendered = _append_parity_chapters(rendered, packet, "en")
|
||||
rendered = _append_research_chapters(rendered, packet, "en")
|
||||
return _apply_local_report_conventions(rendered, language="en")
|
||||
zh_structured_appendix = re.sub(
|
||||
r"^#\s+.*?(?:\n{2,}|\n)",
|
||||
@@ -2439,6 +2454,7 @@ def render_full_data_markdown(packet: dict) -> str:
|
||||
)
|
||||
rendered = _sanitize_pl9_ai_density_markdown(zh_markdown, _pl9_public_report_title(packet))
|
||||
rendered = _append_parity_chapters(rendered, packet, "zh")
|
||||
rendered = _append_research_chapters(rendered, packet, "zh")
|
||||
return _apply_local_report_conventions(rendered, language="zh")
|
||||
|
||||
|
||||
|
||||
@@ -1313,9 +1313,6 @@ def render_pl9_parity_markdown(packet: dict) -> str:
|
||||
('D40_Khavedamsa', 'D40 Khavedamsa'),
|
||||
('D45_Akshavedamsa', 'D45 Akshavedamsa'),
|
||||
('D60_Shashtyamsa', 'D60 Shashtyamsa'),
|
||||
('D81', 'D81'),
|
||||
('D108', 'D108'),
|
||||
('D144', 'D144'),
|
||||
]
|
||||
signs = globals().get('SIGNS', [])
|
||||
for chart_key, chart_label in chart_numeric_specs:
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Offline PyJHora comparison for the research timing appendix.
|
||||
|
||||
Pytest does not import this file. The runtime report does not import it either.
|
||||
If PyJHora is absent, the result is ``not_run`` and the report grows no mismatch label.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
out = root / "docs" / "tasks" / "research-timing-pyjhora-20261023.json"
|
||||
try:
|
||||
import jhora # noqa: F401
|
||||
except ImportError as exc:
|
||||
payload = {
|
||||
"status": "not_run",
|
||||
"charts": 0,
|
||||
"reason": "PyJHora is not installed in this interpreter",
|
||||
"error_type": type(exc).__name__,
|
||||
"mismatches": [],
|
||||
}
|
||||
out.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
print(out)
|
||||
print("not_run")
|
||||
return 0
|
||||
payload = {
|
||||
"status": "not_compared",
|
||||
"charts": 0,
|
||||
"reason": "PyJHora imported, but this pass does not guess a mismatch without a completed comparison",
|
||||
"mismatches": [],
|
||||
}
|
||||
out.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
print(out)
|
||||
print("imported")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1 @@
|
||||
"""Research-only timing and high vargas. Not for chat cards or rectification."""
|
||||
@@ -0,0 +1,474 @@
|
||||
"""Research timing for one chart. Nothing here is written onto the packet.
|
||||
|
||||
The in-process cache is keyed by birth inputs so the two report languages
|
||||
share one ephemeris pass. A failed family keeps its own reason and does not
|
||||
drop the chapter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import swisseph as swe
|
||||
|
||||
try:
|
||||
from research_timing.cycles import (
|
||||
TITHI_FORWARD,
|
||||
TITHI_REVERSE,
|
||||
karana_year,
|
||||
nakshatra_tribhagi,
|
||||
remap_angular,
|
||||
tithi_ashtottari,
|
||||
yoga_vimshottari,
|
||||
)
|
||||
from research_timing.families import (
|
||||
buddhi_gati,
|
||||
hora_sign_from_events,
|
||||
kaala_from_events,
|
||||
karana_seed,
|
||||
research_vargas,
|
||||
saptavarga_lords,
|
||||
sudasa_birth,
|
||||
sudasa_confirmed_anchor,
|
||||
varnada_jss,
|
||||
)
|
||||
from research_timing.frame import SiderealFrame, birth_jd
|
||||
from research_timing.search import (
|
||||
ElongationClock,
|
||||
containing_tithi,
|
||||
find_returns,
|
||||
hindu_solar_events,
|
||||
jd_to_utc,
|
||||
measure_elongation,
|
||||
)
|
||||
except ImportError: # pragma: no cover - package import
|
||||
from scripts.research_timing.cycles import (
|
||||
TITHI_FORWARD,
|
||||
TITHI_REVERSE,
|
||||
karana_year,
|
||||
nakshatra_tribhagi,
|
||||
remap_angular,
|
||||
tithi_ashtottari,
|
||||
yoga_vimshottari,
|
||||
)
|
||||
from scripts.research_timing.families import (
|
||||
buddhi_gati,
|
||||
hora_sign_from_events,
|
||||
kaala_from_events,
|
||||
karana_seed,
|
||||
research_vargas,
|
||||
saptavarga_lords,
|
||||
sudasa_birth,
|
||||
sudasa_confirmed_anchor,
|
||||
varnada_jss,
|
||||
)
|
||||
from scripts.research_timing.frame import SiderealFrame, birth_jd
|
||||
from scripts.research_timing.search import (
|
||||
ElongationClock,
|
||||
containing_tithi,
|
||||
find_returns,
|
||||
hindu_solar_events,
|
||||
jd_to_utc,
|
||||
measure_elongation,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_CACHE: dict[tuple, dict] = {}
|
||||
_PLANETS = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu")
|
||||
|
||||
|
||||
def research_bundle(chart: dict) -> dict:
|
||||
"""Compute every research family for one birth. ``chart`` is not retained."""
|
||||
key = _fingerprint(chart)
|
||||
cached = _CACHE.get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
started = time.perf_counter()
|
||||
built = _build(chart)
|
||||
built["elapsed_seconds"] = time.perf_counter() - started
|
||||
_CACHE[key] = built
|
||||
return built
|
||||
|
||||
|
||||
def _build(chart: dict) -> dict:
|
||||
frame = SiderealFrame(chart["ayanamsa"], chart["node_mode"])
|
||||
natal_jd = birth_jd(chart["year"], chart["month"], chart["day"], chart["hour_decimal"], chart["tz"])
|
||||
sun, moon = frame.sun_moon(natal_jd)
|
||||
longitudes = dict(chart["longitudes"])
|
||||
ascendant = float(chart["ascendant"])
|
||||
epoch = chart["epoch"]
|
||||
year_days = float(chart["year_days"])
|
||||
query = chart["query"]
|
||||
families: dict[str, dict] = {}
|
||||
|
||||
def keep(name: str, producer) -> None:
|
||||
try:
|
||||
families[name] = producer()
|
||||
except (ValueError, RuntimeError, OverflowError, KeyError, TypeError) as exc:
|
||||
logger.warning("research timing %s failed: %s: %s", name, type(exc).__name__, exc)
|
||||
families[name] = {
|
||||
"status": "omitted",
|
||||
"periods": [],
|
||||
"reason_en": "This branch did not finish. The runtime log has the cause.",
|
||||
"reason_zh": "这一支没有算成,原因记在运行日志里。",
|
||||
}
|
||||
|
||||
keep("tithi-natal-forward", lambda: _mark(tithi_ashtottari(
|
||||
epoch, _tithi_number(sun, moon), _arc_remaining(sun, moon), 108 * year_days, TITHI_FORWARD, natal=True,
|
||||
), sun, moon))
|
||||
keep("tithi-natal-reverse", lambda: _mark(tithi_ashtottari(
|
||||
epoch, _tithi_number(sun, moon), _arc_remaining(sun, moon), 108 * year_days, TITHI_REVERSE, natal=True,
|
||||
), sun, moon))
|
||||
keep("yoga-natal", lambda: _mark(yoga_vimshottari(
|
||||
epoch, (sun + moon) % 360.0, 120 * year_days, natal=True, depth=3,
|
||||
), sun, moon))
|
||||
|
||||
annual = _annual_returns(frame, natal_jd, chart["target_year"])
|
||||
families["annual-returns"] = annual
|
||||
_attach_tithi_pairs(keep, frame, natal_jd, annual["returns"]["tithi"], sun, moon)
|
||||
_attach_pravesha(keep, frame, natal_jd, "yoga", annual["returns"]["yoga"], sun, moon)
|
||||
_attach_pravesha(keep, frame, natal_jd, "nakshatra", annual["returns"]["nakshatra"], sun, moon)
|
||||
keep("karana", lambda: _mark(_karana(frame, annual, chart, year_days), sun, moon))
|
||||
|
||||
keep("saptavarga", lambda: {"status": "calculated", "rows": saptavarga_lords(longitudes, ascendant), "birth_sun": sun, "birth_moon": moon})
|
||||
keep("buddhi-gati", lambda: _mark(buddhi_gati(int(ascendant // 30), longitudes, epoch, year_days), sun, moon))
|
||||
events = hindu_solar_events(epoch, chart["lat"], chart["lon"])
|
||||
keep("kaala", lambda: _mark(kaala_from_events(epoch, events, year_days), sun, moon))
|
||||
keep("sudasa-confirmed", lambda: _mark(sudasa_confirmed_anchor(), sun, moon))
|
||||
keep("sudasa-birth", lambda: _mark(sudasa_birth(ascendant, longitudes, epoch, year_days), sun, moon))
|
||||
hora = hora_sign_from_events(epoch, int(ascendant // 30), events)
|
||||
if hora.get("status") != "calculated":
|
||||
families["varnada"] = _mark(hora, sun, moon)
|
||||
else:
|
||||
built = varnada_jss(int(ascendant // 30), int(hora["sign_index"]), longitudes, epoch, year_days)
|
||||
families["varnada"] = {"status": "calculated", "variants": [_mark(item, sun, moon) for item in built], "birth_sun": sun, "birth_moon": moon}
|
||||
keep("research-vargas", lambda: {"status": "calculated", "rows": research_vargas(longitudes, ascendant), "birth_sun": sun, "birth_moon": moon})
|
||||
return {
|
||||
"birth_sun": sun,
|
||||
"birth_moon": moon,
|
||||
"birth_jd": natal_jd,
|
||||
"query": query.isoformat(),
|
||||
"target_year": chart["target_year"],
|
||||
"families": families,
|
||||
"reference_note_en": (
|
||||
"The reference instant is noon on the first day of the first listed report month. "
|
||||
"The packet does not keep a separate today field. "
|
||||
"Yoga and Nakshatra returns use the report target year. "
|
||||
"Tithi pairs use that year and the next two years."
|
||||
),
|
||||
"reference_note_zh": (
|
||||
"参考时刻取报告月历第一行的月份,当月 1 日正午。资料包没有另存阅读当天的时刻。"
|
||||
" Yoga 与 Nakshatra 的回归用这个目标年。 Tithi 配对用该年及后两年。"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _attach_tithi_pairs(keep, frame, natal_jd: float, rows: list[dict], sun: float, moon: float) -> None:
|
||||
"""Every 300–400 day pair. Three target years. Depth stays at two."""
|
||||
for index, row in enumerate(rows, start=1):
|
||||
year = int(row["year"])
|
||||
label = f"tithi-{year}-{index}"
|
||||
try:
|
||||
partners = _later_returns(frame, natal_jd, "tithi_pravesha", row["jd_ut"], 0.0, 400.0)
|
||||
partners = [item for item in partners if 300 <= item["jd_ut"] - row["jd_ut"] <= 400]
|
||||
except (ValueError, RuntimeError, OverflowError) as exc:
|
||||
logger.warning("research timing tithi pair failed: %s: %s", type(exc).__name__, exc)
|
||||
keep(label, lambda: _failed())
|
||||
continue
|
||||
if not partners:
|
||||
keep(label, lambda: {
|
||||
"status": "omitted",
|
||||
"periods": [],
|
||||
"reason_en": "No return falls between 300 and 400 days later, so this year is not listed.",
|
||||
"reason_zh": "之后 300 到 400 天里没有下一次回归,这一年不列表。",
|
||||
})
|
||||
continue
|
||||
stamp = jd_to_utc(row["jd_ut"])
|
||||
arc = _arc_remaining(row["sun_longitude"], row["moon_longitude"])
|
||||
number = _tithi_number(row["sun_longitude"], row["moon_longitude"])
|
||||
for pair_index, partner in enumerate(partners, start=1):
|
||||
span = float(partner["jd_ut"] - row["jd_ut"])
|
||||
for direction, name in ((TITHI_FORWARD, "forward"), (TITHI_REVERSE, "reverse")):
|
||||
keep(
|
||||
f"{label}-pair-{pair_index}-arc-{name}",
|
||||
lambda direction=direction, span=span, stamp=stamp, arc=arc, number=number: _mark(
|
||||
tithi_ashtottari(stamp, number, arc, span, direction, natal=False), sun, moon,
|
||||
),
|
||||
)
|
||||
keep(
|
||||
f"{label}-pair-{pair_index}-time-{name}",
|
||||
lambda direction=direction, span=span, stamp=stamp, number=number, jd=row["jd_ut"]: _mark(
|
||||
_tithi_elapsed(frame, jd, stamp, number, span, direction), sun, moon,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _attach_pravesha(keep, frame, natal_jd: float, technique: str, rows: list[dict], sun: float, moon: float) -> None:
|
||||
"""Reference-year returns. Each successor in the next 300–400 days stays separate."""
|
||||
finder = "yoga_pravesha" if technique == "yoga" else "nakshatra_pravesha"
|
||||
for index, row in enumerate(rows, start=1):
|
||||
stamp = jd_to_utc(row["jd_ut"])
|
||||
try:
|
||||
successors = _later_returns(frame, natal_jd, finder, row["jd_ut"], 300.0, 400.0)
|
||||
except (ValueError, RuntimeError, OverflowError) as exc:
|
||||
logger.warning("research timing %s successor failed: %s: %s", technique, type(exc).__name__, exc)
|
||||
keep(f"{technique}-{index}-successor", lambda: _failed())
|
||||
keep(f"{technique}-{index}-tithi-year", lambda stamp=stamp, jd=row["jd_ut"]: _mark(
|
||||
_remap_year(frame, technique, stamp, jd, None, 4320.0, depth=3), sun, moon,
|
||||
))
|
||||
continue
|
||||
if not successors:
|
||||
keep(f"{technique}-{index}-successor", lambda: {
|
||||
"status": "omitted",
|
||||
"periods": [],
|
||||
"reason_en": "No later return falls between 300 and 400 days, so the successor year is not listed.",
|
||||
"reason_zh": "之后 300 到 400 天里没有下一次回归,后继年这里不列表。",
|
||||
})
|
||||
for pair_index, partner in enumerate(successors, start=1):
|
||||
keep(
|
||||
f"{technique}-{index}-successor-{pair_index}",
|
||||
lambda stamp=stamp, jd=row["jd_ut"], successor=partner["jd_ut"]: _mark(
|
||||
_remap_year(frame, technique, stamp, jd, successor, measure_elongation(frame, jd, successor), depth=3),
|
||||
sun, moon,
|
||||
),
|
||||
)
|
||||
keep(
|
||||
f"{technique}-{index}-tithi-year",
|
||||
lambda stamp=stamp, jd=row["jd_ut"]: _mark(
|
||||
_remap_year(frame, technique, stamp, jd, None, 4320.0, depth=3), sun, moon,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _tithi_elapsed(frame, jd: float, stamp: datetime, number: int, span: float, direction: str) -> dict:
|
||||
held = containing_tithi(frame, jd)
|
||||
return tithi_ashtottari(stamp, number, held["remaining_time_fraction"], span, direction, natal=False)
|
||||
|
||||
|
||||
def _remap_year(frame, technique: str, stamp: datetime, return_jd: float, successor: float | None, angle: float, depth: int) -> dict:
|
||||
if technique == "yoga":
|
||||
sun, moon = frame.sun_moon(return_jd)
|
||||
template = yoga_vimshottari(stamp, (sun + moon) % 360.0, 360.0, natal=False, depth=depth)
|
||||
else:
|
||||
_sun, moon = frame.sun_moon(return_jd)
|
||||
template = nakshatra_tribhagi(stamp, moon % 360.0, 360.0, depth=depth)
|
||||
clock = ElongationClock(frame, return_jd, angle, successor)
|
||||
template["periods"] = remap_angular(template["periods"], clock, return_jd)
|
||||
return template
|
||||
|
||||
|
||||
def _annual_returns(frame: SiderealFrame, natal_jd: float, target_year: int) -> dict:
|
||||
"""Tithi roots for three years. Yoga and nakshatra roots for the target year only."""
|
||||
tithi = []
|
||||
for year in range(int(target_year), int(target_year) + 3):
|
||||
begin = float(swe.julday(year, 1, 1, 0.0))
|
||||
end = float(swe.julday(year + 1, 1, 1, 0.0))
|
||||
for row in find_returns(frame, "tithi_pravesha", natal_jd, begin, end)["returns"]:
|
||||
copied = dict(row)
|
||||
copied["year"] = year
|
||||
tithi.append(copied)
|
||||
begin = float(swe.julday(int(target_year), 1, 1, 0.0))
|
||||
end = float(swe.julday(int(target_year) + 1, 1, 1, 0.0))
|
||||
return {
|
||||
"status": "calculated",
|
||||
"returns": {
|
||||
"tithi": tithi,
|
||||
"yoga": list(find_returns(frame, "yoga_pravesha", natal_jd, begin, end)["returns"]),
|
||||
"nakshatra": list(find_returns(frame, "nakshatra_pravesha", natal_jd, begin, end)["returns"]),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _later_returns(frame, natal_jd: float, technique: str, jd: float, start_offset: float, end_offset: float) -> list[dict]:
|
||||
begin = float(jd) + float(start_offset)
|
||||
end = float(jd) + float(end_offset)
|
||||
if start_offset <= 0:
|
||||
begin = float(jd) - 1e-4
|
||||
end = begin + 400.0
|
||||
found = find_returns(frame, technique, natal_jd, begin, end)["returns"]
|
||||
return [item for item in found if item["jd_ut"] > float(jd) + 1e-6]
|
||||
|
||||
|
||||
def _failed() -> dict:
|
||||
return {
|
||||
"status": "omitted",
|
||||
"periods": [],
|
||||
"reason_en": "This branch did not finish. The runtime log has the cause.",
|
||||
"reason_zh": "这一支没有算成,原因记在运行日志里。",
|
||||
}
|
||||
|
||||
|
||||
def _tithi_number(sun: float, moon: float) -> int:
|
||||
return int(((moon - sun) % 360.0) // 12) + 1
|
||||
|
||||
|
||||
def _arc_remaining(sun: float, moon: float) -> float:
|
||||
return 1.0 - (((moon - sun) % 360.0) % 12.0) / 12.0
|
||||
|
||||
|
||||
def _mark(result: dict, sun: float, moon: float) -> dict:
|
||||
result = dict(result)
|
||||
result["birth_sun"] = sun
|
||||
result["birth_moon"] = moon
|
||||
return result
|
||||
|
||||
|
||||
def _fingerprint(chart: dict) -> tuple:
|
||||
return (
|
||||
chart["year"], chart["month"], chart["day"], round(chart["hour_decimal"], 6),
|
||||
round(chart["lat"], 6), round(chart["lon"], 6), round(chart["tz"], 4),
|
||||
chart["ayanamsa"], chart["node_mode"], chart["target_year"],
|
||||
chart["query"].isoformat(), round(chart["year_days"], 6),
|
||||
round(chart["ascendant"], 8),
|
||||
tuple(round(float(chart["longitudes"][name]), 8) for name in _PLANETS),
|
||||
)
|
||||
|
||||
|
||||
def _karana(frame: SiderealFrame, annual: dict, chart: dict, year_days: float) -> dict:
|
||||
"""Each target-year tithi return, with Julene, PVR, and the 360-tithi PVR year."""
|
||||
rows = [
|
||||
row for row in ((annual.get("returns") or {}).get("tithi") or [])
|
||||
if int(row.get("year", -1)) == int(chart["target_year"])
|
||||
]
|
||||
if not rows:
|
||||
return {
|
||||
"status": "omitted",
|
||||
"periods": [],
|
||||
"reason_en": "No tithi return falls in the target year, so the karana year is not listed.",
|
||||
"reason_zh": "目标年没有 Tithi 回归,Karana 年这里不列表。",
|
||||
}
|
||||
groups = []
|
||||
for index, row in enumerate(rows, start=1):
|
||||
groups.append({
|
||||
"index": index,
|
||||
"variants": _karana_variants(frame, chart, year_days, row),
|
||||
})
|
||||
return {"status": "calculated", "returns": groups}
|
||||
|
||||
|
||||
def _karana_variants(frame: SiderealFrame, chart: dict, year_days: float, row: dict) -> list[dict]:
|
||||
stamp = jd_to_utc(row["jd_ut"])
|
||||
sun, moon = row["sun_longitude"], row["moon_longitude"]
|
||||
ascendant = _return_ascendant(chart, row["jd_ut"])
|
||||
return_jd = row["jd_ut"]
|
||||
variants = []
|
||||
for profile in ("julene", "pvr"):
|
||||
seed = karana_seed(sun, moon, ascendant if profile == "pvr" else None, profile)
|
||||
if seed.get("status") != "calculated":
|
||||
variants.append({"profile": profile, **seed, "periods": []})
|
||||
continue
|
||||
built = karana_year(stamp, seed["lord"], seed["remaining_fraction"], float(year_days))
|
||||
built["profile"] = profile
|
||||
built["karana"] = seed["karana"]
|
||||
built["reason_en"] = "The research passage does not supply child periods."
|
||||
built["reason_zh"] = "这份研究材料没有给出子运,所以这里只列主运。"
|
||||
variants.append(built)
|
||||
pvr = next((item for item in variants if item.get("profile") == "pvr" and item.get("status") == "calculated"), None)
|
||||
if pvr is None:
|
||||
variants.append({
|
||||
"profile": "pvr-tithi-year",
|
||||
"status": "omitted",
|
||||
"periods": [],
|
||||
"reason_en": "The quadrant lord is not available, so the tithi-year cycle is not listed.",
|
||||
"reason_zh": "象限主星没有得到,360 个太阴月的年这里不列表。",
|
||||
})
|
||||
else:
|
||||
try:
|
||||
template = karana_year(stamp, pvr["seed_lord"], pvr["remaining_fraction"], 360.0)
|
||||
clock = ElongationClock(frame, return_jd, 4320.0, None)
|
||||
template["periods"] = remap_angular(template["periods"], clock, return_jd)
|
||||
template["profile"] = "pvr-tithi-year"
|
||||
template["karana"] = pvr.get("karana")
|
||||
template["reason_en"] = "The research passage does not supply child periods."
|
||||
template["reason_zh"] = "这份研究材料没有给出子运,所以这里只列主运。"
|
||||
except (ValueError, RuntimeError) as exc:
|
||||
logger.warning("research timing karana tithi year failed: %s: %s", type(exc).__name__, exc)
|
||||
template = {
|
||||
"profile": "pvr-tithi-year",
|
||||
"status": "omitted",
|
||||
"periods": [],
|
||||
"reason_en": "The tithi-year cycle did not finish. The runtime log has the cause.",
|
||||
"reason_zh": "360 个太阴月的年没有算成,原因记在运行日志里。",
|
||||
}
|
||||
variants.append(template)
|
||||
return variants
|
||||
|
||||
|
||||
def chart_birth_jd(chart: dict) -> float:
|
||||
return birth_jd(chart["year"], chart["month"], chart["day"], chart["hour_decimal"], chart["tz"])
|
||||
|
||||
|
||||
def _return_ascendant(chart: dict, jd: float) -> float:
|
||||
try:
|
||||
from jyotish_engine import compute_chart_data
|
||||
except ImportError: # pragma: no cover - package import
|
||||
from scripts.jyotish_engine import compute_chart_data
|
||||
local = jd_to_utc(jd).astimezone(timezone(timedelta(hours=chart["tz"]))).replace(microsecond=0)
|
||||
found, _index, chart_jd, ayanamsa_value = compute_chart_data(
|
||||
local.year, local.month, local.day, local.hour, local.minute,
|
||||
chart["lat"], chart["lon"], chart["tz"],
|
||||
node_mode=chart["node_mode"], second=local.second, ayanamsa_name=chart["ayanamsa"],
|
||||
)
|
||||
if not isinstance(found, dict):
|
||||
raise RuntimeError("return ascendant is unavailable")
|
||||
houses, _ascmc = swe.houses(float(chart_jd), chart["lat"], chart["lon"], b"A")
|
||||
return (float(houses[0]) - float(ayanamsa_value)) % 360.0
|
||||
|
||||
|
||||
def chart_from_parts(
|
||||
*,
|
||||
year: int,
|
||||
month: int,
|
||||
day: int,
|
||||
hour: int,
|
||||
minute: int,
|
||||
second: int,
|
||||
lat: float,
|
||||
lon: float,
|
||||
tz: float,
|
||||
ayanamsa: str,
|
||||
node_mode: str,
|
||||
target_year: int,
|
||||
query: datetime,
|
||||
year_days: float = 365.25,
|
||||
) -> dict:
|
||||
"""Build the calculator input from a chart function result. Positions stay on our frame."""
|
||||
try:
|
||||
from jyotish_engine import compute_chart_data
|
||||
except ImportError: # pragma: no cover - package import
|
||||
from scripts.jyotish_engine import compute_chart_data
|
||||
chart, _asc_index, jd, ayanamsa_value = compute_chart_data(
|
||||
int(year), int(month), int(day), int(hour), int(minute),
|
||||
float(lat), float(lon), float(tz),
|
||||
node_mode=node_mode, second=int(second), ayanamsa_name=ayanamsa,
|
||||
)
|
||||
if not isinstance(chart, dict):
|
||||
raise RuntimeError("chart function returned no positions")
|
||||
houses, _ascmc = swe.houses(float(jd), float(lat), float(lon), b"A")
|
||||
ascendant = (float(houses[0]) - float(ayanamsa_value)) % 360.0
|
||||
longitudes = {name: float(chart["planets"][name]["degree_raw"]) for name in _PLANETS}
|
||||
epoch = datetime(int(year), int(month), int(day), int(hour), int(minute), int(second),
|
||||
tzinfo=timezone(timedelta(hours=float(tz))))
|
||||
if query.utcoffset() is None:
|
||||
query = query.replace(tzinfo=timezone(timedelta(hours=float(tz))))
|
||||
return {
|
||||
"year": int(year),
|
||||
"month": int(month),
|
||||
"day": int(day),
|
||||
"hour_decimal": int(hour) + int(minute) / 60.0 + int(second) / 3600.0,
|
||||
"lat": float(lat),
|
||||
"lon": float(lon),
|
||||
"tz": float(tz),
|
||||
"ayanamsa": str(ayanamsa or "lahiri"),
|
||||
"node_mode": node_mode if node_mode in ("mean", "true") else "mean",
|
||||
"target_year": int(target_year),
|
||||
"query": query,
|
||||
"year_days": float(year_days),
|
||||
"epoch": epoch,
|
||||
"ascendant": ascendant,
|
||||
"longitudes": longitudes,
|
||||
"chart_sun": longitudes["Sun"],
|
||||
"chart_moon": longitudes["Moon"],
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
"""Period arithmetic copied from the named research profiles.
|
||||
|
||||
No ephemeris here. Depth stops where the source says there is no child rule.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
try:
|
||||
from jyotish_engine import DASHA_ORDER, DASHA_YEARS
|
||||
except ImportError: # pragma: no cover - package import
|
||||
from scripts.jyotish_engine import DASHA_ORDER, DASHA_YEARS
|
||||
|
||||
TITHI_LORDS = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu")
|
||||
TITHI_SEQUENCE = ("Sun", "Moon", "Mars", "Mercury", "Saturn", "Jupiter", "Rahu", "Venus")
|
||||
TITHI_YEARS = (6, 15, 8, 17, 10, 19, 12, 21)
|
||||
TITHI_FORWARD = "forward"
|
||||
TITHI_REVERSE = "reverse"
|
||||
KARANA_LORDS = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn")
|
||||
KAALA_LORDS = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu")
|
||||
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",
|
||||
}
|
||||
|
||||
|
||||
def _aware(epoch: datetime) -> datetime:
|
||||
if epoch.utcoffset() is None:
|
||||
raise ValueError("epoch requires a UTC offset")
|
||||
return epoch.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def tithi_seed(tithi_num: int) -> str:
|
||||
if not 1 <= int(tithi_num) <= 30:
|
||||
raise ValueError("tithi number must be 1..30")
|
||||
if int(tithi_num) == 30:
|
||||
return "Rahu"
|
||||
return TITHI_LORDS[((int(tithi_num) - 1) % 15) % 8]
|
||||
|
||||
|
||||
def tithi_ashtottari(
|
||||
epoch: datetime,
|
||||
tithi_num: int,
|
||||
remaining_fraction: float,
|
||||
span_days: float,
|
||||
direction: str,
|
||||
*,
|
||||
natal: bool,
|
||||
) -> dict:
|
||||
"""108-part cycle. Annual span is 300..400 days. Natal span is one 108-year cycle. No third level."""
|
||||
if direction not in (TITHI_FORWARD, TITHI_REVERSE):
|
||||
raise ValueError("tithi direction must be forward or reverse")
|
||||
if not 0 <= remaining_fraction <= 1:
|
||||
raise ValueError("remaining fraction must be 0..1")
|
||||
low, high = (108 * 350, 108 * 370) if natal else (300, 400)
|
||||
if not low <= span_days <= high:
|
||||
raise ValueError("span is outside the selected calendar")
|
||||
stamp = _aware(epoch)
|
||||
seed = tithi_seed(tithi_num)
|
||||
seed_index = TITHI_SEQUENCE.index(seed)
|
||||
unit = span_days / 108.0
|
||||
offset = -TITHI_YEARS[seed_index] * (1 - remaining_fraction) * unit
|
||||
window = span_days if not natal else span_days + offset
|
||||
step = 1 if direction == TITHI_FORWARD else -1
|
||||
periods = []
|
||||
count = 8 if natal else 9
|
||||
for number in range(count):
|
||||
index = (seed_index + number) % 8
|
||||
end = offset + TITHI_YEARS[index] * unit
|
||||
visible_left, visible_right = max(offset, 0.0), min(end, window)
|
||||
if visible_right - visible_left > 1e-10:
|
||||
row = _span_row(stamp, TITHI_SEQUENCE[index], f"md:{number + 1}", offset, end, visible_left, visible_right)
|
||||
children = []
|
||||
child_start = offset
|
||||
for child_number in range(8):
|
||||
child_index = (index + step * (child_number + 1)) % 8
|
||||
child_end = end if child_number == 7 else child_start + (end - offset) * TITHI_YEARS[child_index] / 108.0
|
||||
child_left, child_right = max(child_start, visible_left), min(child_end, visible_right)
|
||||
if child_right - child_left > 1e-10:
|
||||
children.append(_span_row(
|
||||
stamp, TITHI_SEQUENCE[child_index], f"md:{number + 1}:ad:{child_number + 1}",
|
||||
child_start, child_end, child_left, child_right,
|
||||
))
|
||||
child_start = child_end
|
||||
row["antardashas"] = children
|
||||
periods.append(row)
|
||||
offset = end
|
||||
return {
|
||||
"status": "calculated",
|
||||
"seed_lord": seed,
|
||||
"tithi_num": int(tithi_num),
|
||||
"remaining_fraction": remaining_fraction,
|
||||
"span_days": span_days,
|
||||
"direction": direction,
|
||||
"periods": periods,
|
||||
"pd_status": "omitted",
|
||||
"reason_en": "No explicit third-level rule in the admitted tutorial.",
|
||||
"reason_zh": "收录的教程没有写明第三层。",
|
||||
}
|
||||
|
||||
|
||||
def yoga_vimshottari(epoch: datetime, sun_moon_sum: float, span_days: float, *, natal: bool, depth: int = 3) -> dict:
|
||||
if depth not in (1, 2, 3):
|
||||
raise ValueError("yoga depth must be 1, 2 or 3")
|
||||
if not 0 <= sun_moon_sum < 360:
|
||||
raise ValueError("yoga angle must be in [0, 360)")
|
||||
low, high = (120 * 350, 120 * 370) if natal else (300, 400)
|
||||
if not low <= span_days <= high:
|
||||
raise ValueError("yoga span is outside the selected calendar")
|
||||
stamp = _aware(epoch)
|
||||
coordinate = sun_moon_sum / (360.0 / 27.0)
|
||||
yoga_index = int(coordinate)
|
||||
elapsed_fraction = coordinate - yoga_index
|
||||
seed_index = (yoga_index + DASHA_ORDER.index("Saturn")) % 9
|
||||
seed = DASHA_ORDER[seed_index]
|
||||
unit = span_days / 120.0
|
||||
full_start_day = -elapsed_fraction * DASHA_YEARS[seed] * unit
|
||||
# Natal shows one clipped 120-year cycle. Annual adds the returning seed fragment.
|
||||
count = 9 if natal else 10
|
||||
periods = []
|
||||
cursor = full_start_day
|
||||
window_end = span_days if not natal else span_days + full_start_day
|
||||
for number in range(count):
|
||||
lord = DASHA_ORDER[(seed_index + number) % 9]
|
||||
full_end = cursor + DASHA_YEARS[lord] * unit
|
||||
left, right = max(cursor, 0.0), min(full_end, window_end if natal else span_days)
|
||||
if right - left > 1e-8:
|
||||
row = _day_row(stamp, lord, f"md:{number + 1}", cursor, full_end, left, right)
|
||||
if depth > 1:
|
||||
row["antardashas"] = _vimshottari_children(stamp, row, depth, "ad")
|
||||
periods.append(row)
|
||||
cursor = full_end
|
||||
return {
|
||||
"status": "calculated",
|
||||
"seed_lord": seed,
|
||||
"yoga_index": yoga_index + 1,
|
||||
"remaining_fraction": 1 - elapsed_fraction,
|
||||
"span_days": span_days,
|
||||
"depth": depth,
|
||||
"periods": periods,
|
||||
}
|
||||
|
||||
|
||||
def nakshatra_tribhagi(epoch: datetime, moon_longitude: float, span_days: float, depth: int = 3) -> dict:
|
||||
if depth not in (1, 2, 3):
|
||||
raise ValueError("nakshatra depth must be 1, 2 or 3")
|
||||
if not 0 <= moon_longitude < 360 or not 300 <= span_days <= 400:
|
||||
raise ValueError("nakshatra longitude or span is out of range")
|
||||
stamp = _aware(epoch)
|
||||
coordinate = moon_longitude / (360.0 / 27.0)
|
||||
seed_star = int(coordinate)
|
||||
fraction = coordinate - seed_star
|
||||
seed_index = seed_star % 9
|
||||
seed = DASHA_ORDER[seed_index]
|
||||
unit = span_days / 120.0
|
||||
cursor = -fraction * (DASHA_YEARS[seed] / 3.0) * unit
|
||||
periods = []
|
||||
for number in range(28):
|
||||
lord = DASHA_ORDER[(seed_index + number) % 9]
|
||||
full_end = cursor + (DASHA_YEARS[lord] / 3.0) * unit
|
||||
left, right = max(cursor, 0.0), min(full_end, span_days)
|
||||
if right - left > 1e-8:
|
||||
row = _day_row(stamp, lord, f"tribhagi:md:{number + 1}", cursor, full_end, left, right)
|
||||
row["cycle_index"] = number // 9 + 1
|
||||
if depth > 1:
|
||||
row["antardashas"] = _tribhagi_children(stamp, row, depth, "ad")
|
||||
periods.append(row)
|
||||
cursor = full_end
|
||||
return {"status": "calculated", "seed_lord": seed, "span_days": span_days, "depth": depth, "periods": periods}
|
||||
|
||||
|
||||
def _vimshottari_children(stamp: datetime, parent: dict, depth: int, level: str) -> list[dict]:
|
||||
start = parent["full_start_days"]
|
||||
span = parent["full_end_days"] - start
|
||||
first = DASHA_ORDER.index(parent["lord"])
|
||||
rows = []
|
||||
for index in range(9):
|
||||
lord = DASHA_ORDER[(first + index) % 9]
|
||||
end = parent["full_end_days"] if index == 8 else start + span * DASHA_YEARS[lord] / 120.0
|
||||
left, right = max(start, parent["start_days"]), min(end, parent["end_days"])
|
||||
if right - left > 1e-8:
|
||||
row = _day_row(stamp, lord, f"{parent['key']}:{level}:{index + 1}", start, end, left, right)
|
||||
if level == "ad" and depth == 3:
|
||||
row["pratyantardashas"] = _vimshottari_children(stamp, row, depth, "pd")
|
||||
rows.append(row)
|
||||
start = end
|
||||
return rows
|
||||
|
||||
|
||||
def _tribhagi_children(stamp: datetime, parent: dict, depth: int, level: str) -> list[dict]:
|
||||
start = parent["full_start_days"]
|
||||
span = parent["full_end_days"] - start
|
||||
first = DASHA_ORDER.index(parent["lord"])
|
||||
rows = []
|
||||
for index in range(9):
|
||||
lord = DASHA_ORDER[(first + index) % 9]
|
||||
end = parent["full_end_days"] if index == 8 else start + span * (DASHA_YEARS[lord] / 3.0) / 40.0
|
||||
left, right = max(start, parent["start_days"]), min(end, parent["end_days"])
|
||||
if right - left > 1e-8:
|
||||
row = _day_row(stamp, lord, f"{parent['key']}:{level}:{index + 1}", start, end, left, right)
|
||||
if level == "ad" and depth == 3:
|
||||
row["pratyantardashas"] = _tribhagi_children(stamp, row, depth, "pd")
|
||||
rows.append(row)
|
||||
start = end
|
||||
return rows
|
||||
|
||||
|
||||
def karana_year(epoch: datetime, lord: str, remaining_fraction: float, span_days: float) -> dict:
|
||||
if lord not in KARANA_LORDS:
|
||||
raise ValueError("karana seed must be Sun through Saturn")
|
||||
if not 0 <= remaining_fraction <= 1 or not 300 <= span_days <= 400:
|
||||
raise ValueError("karana fraction or span is out of range")
|
||||
stamp = _aware(epoch)
|
||||
seed = KARANA_LORDS.index(lord)
|
||||
whole = span_days / 7.0
|
||||
offset = -(1 - remaining_fraction) * whole
|
||||
periods = []
|
||||
for index in range(8):
|
||||
start = offset + index * whole
|
||||
end = offset + (index + 1) * whole
|
||||
left, right = max(0.0, start), min(span_days, end)
|
||||
if right > left:
|
||||
periods.append(_day_row(stamp, KARANA_LORDS[(seed + index) % 7], f"karana:md:{index + 1}", start, end, left, right))
|
||||
return {
|
||||
"status": "calculated",
|
||||
"seed_lord": lord,
|
||||
"remaining_fraction": remaining_fraction,
|
||||
"span_days": span_days,
|
||||
"periods": periods,
|
||||
"pd_status": "blocked",
|
||||
"pd_reason": "The research passage does not supply child periods",
|
||||
}
|
||||
|
||||
|
||||
def remap_angular(periods: list[dict], clock, return_jd: float) -> list[dict]:
|
||||
"""Replace coordinate-day offsets with elongation-clock instants. Tree shape stays."""
|
||||
|
||||
def convert(row: dict) -> dict:
|
||||
out = dict(row)
|
||||
for key in ("start_days", "end_days"):
|
||||
solved = clock.solve(row[key])
|
||||
out[key.replace("_days", "_jd")] = solved
|
||||
out["start"] = _iso(out["start_jd"])
|
||||
out["end"] = _iso(out["end_jd"])
|
||||
for child_key in ("antardashas", "pratyantardashas"):
|
||||
if child_key in row:
|
||||
out[child_key] = [convert(child) for child in row[child_key]]
|
||||
return out
|
||||
|
||||
del return_jd
|
||||
return [convert(row) for row in periods]
|
||||
|
||||
|
||||
def kaala_from_segment(epoch: datetime, segment_start: datetime, segment_end: datetime, year_days: float) -> dict:
|
||||
start = _aware(epoch)
|
||||
if not segment_start <= start < segment_end:
|
||||
raise ValueError("birth is outside the selected solar segment")
|
||||
fraction = (start - segment_start).total_seconds() / (segment_end - segment_start).total_seconds()
|
||||
cycles = (120.0 * fraction, 120.0 * (1.0 - fraction))
|
||||
periods = []
|
||||
elapsed = 0.0
|
||||
for cycle, years in enumerate(cycles, start=1):
|
||||
cycle_start = elapsed
|
||||
for ordinal, lord in enumerate(KAALA_LORDS, start=1):
|
||||
left = cycle_start + years * (ordinal - 1) * ordinal / 90.0
|
||||
right = cycle_start + years * ordinal * (ordinal + 1) / 90.0
|
||||
periods.append({
|
||||
"lord": lord,
|
||||
"cycle": cycle,
|
||||
"years": years * ordinal / 45.0,
|
||||
"start": (start + timedelta(days=left * year_days)).isoformat(),
|
||||
"end": (start + timedelta(days=right * year_days)).isoformat(),
|
||||
"level": "MD",
|
||||
})
|
||||
elapsed = cycle_start + years
|
||||
return {
|
||||
"status": "calculated",
|
||||
"elapsed_fraction": fraction,
|
||||
"periods": periods,
|
||||
"pd_status": "blocked",
|
||||
"pd_reason": "The Kaala note does not prescribe child periods",
|
||||
}
|
||||
|
||||
|
||||
def _span_row(stamp, lord, key, full_start, full_end, left, right) -> dict:
|
||||
row = _day_row(stamp, lord, key, full_start, full_end, left, right)
|
||||
return row
|
||||
|
||||
|
||||
def _day_row(stamp: datetime, lord: str, key: str, full_start: float, full_end: float, left: float, right: float) -> dict:
|
||||
return {
|
||||
"key": key,
|
||||
"lord": lord,
|
||||
"level": "PD" if ":pd:" in key else "AD" if ":ad:" in key else "MD",
|
||||
"full_start_days": full_start,
|
||||
"full_end_days": full_end,
|
||||
"start_days": left,
|
||||
"end_days": right,
|
||||
"start": (stamp + timedelta(days=left)).isoformat(),
|
||||
"end": (stamp + timedelta(days=right)).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def _iso(jd: float) -> str:
|
||||
try:
|
||||
from research_timing.search import jd_to_utc
|
||||
except ImportError: # pragma: no cover - package import
|
||||
from scripts.research_timing.search import jd_to_utc
|
||||
return jd_to_utc(jd).isoformat()
|
||||
@@ -0,0 +1,427 @@
|
||||
"""Research dasha arithmetic that does not touch the ephemeris.
|
||||
|
||||
Longitude values are supplied by the caller from this repository's chart
|
||||
function. Child levels stop where the written source stops.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
try:
|
||||
from chaturshitisama_dasha import DASHA_SEQUENCE
|
||||
from muhurta import calc_karana
|
||||
from narayana_dasha import _NARAYANA_DIGNITY, calc_narayana_mahadasha
|
||||
from varga import SIGN_LORDS, SIGNS, calc_varga
|
||||
except ImportError: # pragma: no cover - package import
|
||||
from scripts.chaturshitisama_dasha import DASHA_SEQUENCE
|
||||
from scripts.muhurta import calc_karana
|
||||
from scripts.narayana_dasha import _NARAYANA_DIGNITY, calc_narayana_mahadasha
|
||||
from scripts.varga import SIGN_LORDS, SIGNS, calc_varga
|
||||
|
||||
JULENE_LORDS = {
|
||||
"Bava": "Sun", "Balava": "Moon", "Kaulava": "Mars", "Taitila": "Mercury",
|
||||
"Garija": "Jupiter", "Vanija": "Venus", "Vishti": "Saturn",
|
||||
"Shakuni": "Rahu", "Chatushpada": "Ketu", "Naga": "Rahu", "Kimstughna": "Ketu",
|
||||
}
|
||||
PVR_MOVABLE = {
|
||||
"Bava": "Sun", "Balava": "Moon", "Kaulava": "Mars", "Taitila": "Mercury",
|
||||
"Garija": "Jupiter", "Vanija": "Venus", "Vishti": "Saturn",
|
||||
}
|
||||
PVR_FIXED_HOUSES = {"Shakuni": 1, "Chatushpada": 4, "Naga": 7, "Kimstughna": 10}
|
||||
SAPTAVARGA = (1, 2, 3, 7, 9, 12, 30)
|
||||
SAPTAVARGA_BODIES = ("Lagna", "Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn")
|
||||
RESEARCH_DIVISIONS = (81, 108, 144)
|
||||
CLASSICAL = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu")
|
||||
|
||||
|
||||
def _aware(epoch: datetime) -> datetime:
|
||||
if epoch.utcoffset() is None:
|
||||
raise ValueError("epoch requires a UTC offset")
|
||||
return epoch.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _at(epoch: datetime, years: float, year_days: float) -> str:
|
||||
return (_aware(epoch) + timedelta(days=float(years) * float(year_days))).isoformat()
|
||||
|
||||
|
||||
def karana_seed(sun: float, moon: float, ascendant: float | None, profile: str) -> dict:
|
||||
"""Julene table, or PVR movable lords plus quadrant lords for the four fixed names."""
|
||||
if profile not in ("julene", "pvr"):
|
||||
raise ValueError("karana lord profile must be julene or pvr")
|
||||
found = calc_karana(float(sun), float(moon))
|
||||
name = found["karana"]
|
||||
phase = (float(moon) - float(sun)) % 360.0
|
||||
if profile == "julene":
|
||||
lord = JULENE_LORDS.get(name)
|
||||
else:
|
||||
lord = PVR_MOVABLE.get(name)
|
||||
if lord is None and name in PVR_FIXED_HOUSES:
|
||||
if ascendant is None:
|
||||
return {"status": "omitted", "karana": name, "reason_en": "The quadrant lord needs the ascendant at the return.", "reason_zh": "象限主星需要返照时刻的上升点,这次没有算到。"}
|
||||
house = PVR_FIXED_HOUSES[name]
|
||||
sign = SIGNS[(int(float(ascendant) // 30) + house - 1) % 12]
|
||||
lord = SIGN_LORDS[sign]
|
||||
if lord in ("Rahu", "Ketu") or lord is None:
|
||||
return {
|
||||
"status": "omitted",
|
||||
"karana": name,
|
||||
"lord": lord,
|
||||
"reason_en": "This karana lord is a node, so the seven-planet cycle is not listed.",
|
||||
"reason_zh": "这个迦那的主星是交点,七曜周期这里不列表。",
|
||||
}
|
||||
return {
|
||||
"status": "calculated",
|
||||
"karana": name,
|
||||
"lord": lord,
|
||||
"remaining_fraction": 1.0 - (phase % 6.0) / 6.0,
|
||||
}
|
||||
|
||||
|
||||
def saptavarga_lords(longitudes: dict[str, float], ascendant: float) -> list[dict]:
|
||||
points = {"Lagna": float(ascendant), **{name: float(longitudes[name]) for name in SAPTAVARGA_BODIES if name != "Lagna"}}
|
||||
rows = []
|
||||
for division in SAPTAVARGA:
|
||||
for body, longitude in points.items():
|
||||
if division == 1:
|
||||
sign = SIGNS[int(longitude // 30) % 12]
|
||||
degree = longitude % 30.0
|
||||
lord = SIGN_LORDS[sign]
|
||||
else:
|
||||
found = calc_varga(longitude, division)
|
||||
sign = found["sign"]
|
||||
degree = found["degree_in_sign"]
|
||||
lord = found["lord"]
|
||||
rows.append({
|
||||
"division": division,
|
||||
"body": body,
|
||||
"sign": sign,
|
||||
"degree": degree,
|
||||
"lord": lord,
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def research_vargas(longitudes: dict[str, float], ascendant: float) -> list[dict]:
|
||||
points = {"Lagna": float(ascendant), **{name: float(longitudes[name]) for name in CLASSICAL}}
|
||||
rows = []
|
||||
for division in RESEARCH_DIVISIONS:
|
||||
for body, longitude in points.items():
|
||||
found = calc_varga(longitude, division)
|
||||
rows.append({
|
||||
"division": division,
|
||||
"body": body,
|
||||
"sign": found["sign"],
|
||||
"degree": found["degree_in_sign"],
|
||||
"lord": found["lord"],
|
||||
})
|
||||
try:
|
||||
from divisional_charts_extended import DivisionalChartsCalculator
|
||||
except ImportError: # pragma: no cover - package import
|
||||
from scripts.divisional_charts_extended import DivisionalChartsCalculator
|
||||
calculator = DivisionalChartsCalculator()
|
||||
for body, longitude in points.items():
|
||||
found = calculator.calc_custom_varga(float(longitude), 150)
|
||||
rows.append({
|
||||
"division": 150,
|
||||
"body": body,
|
||||
"sign": found.get("sign"),
|
||||
"degree": found.get("degree"),
|
||||
"lord": SIGN_LORDS.get(found.get("sign"), ""),
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def buddhi_gati(lagna_sign: int, longitudes: dict[str, float], epoch: datetime, year_days: float) -> dict:
|
||||
"""One nine-planet pass. The Atri passage does not name child periods."""
|
||||
planets = set(_NARAYANA_DIGNITY) | {"Rahu", "Ketu"}
|
||||
if set(longitudes) < planets:
|
||||
raise ValueError("Buddhi Gati needs all nine longitudes")
|
||||
start = _aware(epoch)
|
||||
fourth = (int(lagna_sign) + 3) % 12
|
||||
order = sorted(planets, key=lambda name: ((int(longitudes[name] // 30) - fourth) % 12, longitudes[name] % 30))
|
||||
periods = []
|
||||
elapsed = 0.0
|
||||
for ordinal, planet in enumerate(order):
|
||||
tied = sorted(name for name in planets if longitudes[name] == longitudes[planet])
|
||||
if len(tied) > 1:
|
||||
return {
|
||||
"status": "omitted",
|
||||
"periods": [],
|
||||
"reason_en": "Two planets share a longitude, and the passage does not say which one comes first.",
|
||||
"reason_zh": "有两颗星黄经相同,材料没有规定先后,所以这里不列表。",
|
||||
}
|
||||
sign = int(longitudes[planet] // 30)
|
||||
target = (int(lagna_sign) + ordinal) % 12
|
||||
base = (target - sign) % 12
|
||||
dignity = _NARAYANA_DIGNITY.get(planet)
|
||||
correction = 0 if dignity is None else int(sign in dignity["exalted"]) - int(sign in dignity["debilitated"])
|
||||
years = base + correction
|
||||
if years <= 0:
|
||||
return {
|
||||
"status": "omitted",
|
||||
"periods": [],
|
||||
"reason_en": "A duration is not positive, and the passage does not say how to continue.",
|
||||
"reason_zh": "有一段年数不是正数,材料没有规定怎么接下去,所以这里不列表。",
|
||||
}
|
||||
periods.append({
|
||||
"lord": planet,
|
||||
"level": "MD",
|
||||
"start": _at(start, elapsed, year_days),
|
||||
"end": _at(start, elapsed + years, year_days),
|
||||
})
|
||||
elapsed += years
|
||||
return {
|
||||
"status": "calculated",
|
||||
"periods": periods,
|
||||
"reason_en": "The Atri passage does not specify child periods.",
|
||||
"reason_zh": "Atri 原文没有规定子运和次小运,所以这里只列主运。",
|
||||
"pd_status": "omitted",
|
||||
}
|
||||
|
||||
|
||||
def kaala_from_events(epoch: datetime, events: list[dict], year_days: float) -> dict:
|
||||
"""Two weighted cycles inside the solar sixth that contains birth. No children."""
|
||||
start = _aware(epoch)
|
||||
parsed = []
|
||||
for event in events:
|
||||
if event.get("event") not in ("sunrise", "sunset"):
|
||||
continue
|
||||
parsed.append((event["event"], datetime.fromisoformat(event["datetime_utc"])))
|
||||
selected = None
|
||||
for index in range(len(parsed) - 3):
|
||||
group = parsed[index:index + 4]
|
||||
if [item[0] for item in group] != ["sunset", "sunrise", "sunset", "sunrise"]:
|
||||
continue
|
||||
previous_set, rise, setting, next_rise = [item[1] for item in group]
|
||||
if any((right - left).total_seconds() >= 86400 or right <= left for left, right in zip(
|
||||
(previous_set, rise, setting), (rise, setting, next_rise),
|
||||
)):
|
||||
continue
|
||||
day = setting - rise
|
||||
before_night = rise - previous_set
|
||||
after_night = next_rise - setting
|
||||
boundaries = [
|
||||
rise - before_night / 6,
|
||||
rise + day / 6,
|
||||
setting - day / 6,
|
||||
setting + after_night / 6,
|
||||
next_rise - after_night / 6,
|
||||
]
|
||||
for name, left, right in zip(("dawn", "daytime", "dusk", "nighttime"), boundaries, boundaries[1:]):
|
||||
if left <= start < right:
|
||||
selected = (name, left, right)
|
||||
break
|
||||
if selected is not None:
|
||||
break
|
||||
if selected is None:
|
||||
return {
|
||||
"status": "omitted",
|
||||
"periods": [],
|
||||
"reason_en": "Sunrise and sunset around birth do not bracket a solar sixth.",
|
||||
"reason_zh": "出生前后的日出日落没有框住一个太阳六分时段,所以这里不列表。",
|
||||
}
|
||||
try:
|
||||
from research_timing.cycles import kaala_from_segment
|
||||
except ImportError: # pragma: no cover - package import
|
||||
from scripts.research_timing.cycles import kaala_from_segment
|
||||
name, left, right = selected
|
||||
built = kaala_from_segment(start, left, right, year_days)
|
||||
built["segment"] = name
|
||||
built["reason_en"] = "The Kaala note does not prescribe child periods."
|
||||
built["reason_zh"] = "Kaala 的材料没有规定子运和次小运,所以这里只列主运。"
|
||||
built["pd_status"] = "omitted"
|
||||
return built
|
||||
|
||||
|
||||
def _sudasa_direction(sign: int, saturn_sign: int, ketu_sign: int) -> tuple[int, str]:
|
||||
if saturn_sign == ketu_sign == sign:
|
||||
raise ValueError("Saturn and Ketu exceptions meet on the same sign")
|
||||
direction = 1 if sign % 2 == 0 else -1
|
||||
if saturn_sign == sign:
|
||||
return 1, "Saturn keeps the forward order"
|
||||
if ketu_sign == sign:
|
||||
return -direction, "Ketu reverses the order"
|
||||
return direction, ""
|
||||
|
||||
|
||||
def _sudasa_children(sign: int, full_years: float, saturn_sign: int, ketu_sign: int, remaining: float) -> list[dict]:
|
||||
direction, _note = _sudasa_direction(sign, saturn_sign, ketu_sign)
|
||||
offsets = [group + quadrant * 3 for group in range(3) for quadrant in range(4)]
|
||||
full = []
|
||||
for index, offset in enumerate(offsets):
|
||||
full.append({
|
||||
"sign_index": (sign + direction * offset) % 12,
|
||||
"start_offset": full_years * index / 12.0,
|
||||
"end_offset": full_years * (index + 1) / 12.0,
|
||||
})
|
||||
if remaining >= 1:
|
||||
return full
|
||||
elapsed = full_years * (1.0 - remaining)
|
||||
clipped = []
|
||||
for row in full:
|
||||
if row["end_offset"] <= elapsed:
|
||||
continue
|
||||
clipped.append({
|
||||
"sign_index": row["sign_index"],
|
||||
"start_offset": max(row["start_offset"], elapsed) - elapsed,
|
||||
"end_offset": row["end_offset"] - elapsed,
|
||||
})
|
||||
return clipped
|
||||
|
||||
|
||||
def sudasa_birth(ascendant: float, longitudes: dict[str, float], epoch: datetime, year_days: float) -> dict:
|
||||
"""Sree lagna from PVR 5.7, SJVC15 order, birth children clipped inside the full major."""
|
||||
moon = float(longitudes["Moon"])
|
||||
sree = (float(ascendant) + (moon * 27.0) % 360.0) % 360.0
|
||||
durations = calc_narayana_mahadasha(
|
||||
0,
|
||||
{name: float(longitudes[name]) for name in CLASSICAL},
|
||||
duration_profile="jaimini_odd_footed_dignity_v2",
|
||||
dual_lord_profile="legacy_primary_lord_v0",
|
||||
)
|
||||
by_sign = {int(row["sign_idx"]): row for row in durations}
|
||||
sign = int(sree // 30)
|
||||
saturn = int(longitudes["Saturn"] // 30)
|
||||
ketu = int(longitudes["Ketu"] // 30)
|
||||
direction, note = _sudasa_direction(sign, saturn, ketu)
|
||||
offsets = [group + quadrant * 3 for group in range(3) for quadrant in range(4)]
|
||||
fraction = (30.0 - (sree % 30.0)) / 30.0
|
||||
periods = []
|
||||
elapsed = 0.0
|
||||
start = _aware(epoch)
|
||||
for index, offset in enumerate(offsets):
|
||||
current = (sign + direction * offset) % 12
|
||||
full_years = float(by_sign[current]["years"])
|
||||
years = full_years * fraction if index == 0 else full_years
|
||||
row = {
|
||||
"lord": by_sign[current].get("lord"),
|
||||
"sign": SIGNS[current],
|
||||
"level": "MD",
|
||||
"start": _at(start, elapsed, year_days),
|
||||
"end": _at(start, elapsed + years, year_days),
|
||||
"antardashas": [],
|
||||
}
|
||||
try:
|
||||
children = _sudasa_children(current, full_years, saturn, ketu, fraction if index == 0 else 1.0)
|
||||
except ValueError:
|
||||
row["note_en"] = "Saturn and Ketu both occupy this sign, so its child periods are not listed."
|
||||
row["note_zh"] = "土星和计都同时落在这个星座,子运这里不列表。"
|
||||
children = []
|
||||
for child in children:
|
||||
child_sign = int(child["sign_index"])
|
||||
row["antardashas"].append({
|
||||
"sign": SIGNS[child_sign],
|
||||
"lord": SIGN_LORDS[SIGNS[child_sign]],
|
||||
"level": "AD",
|
||||
"start": _at(start, elapsed + child["start_offset"], year_days),
|
||||
"end": _at(start, elapsed + child["end_offset"], year_days),
|
||||
})
|
||||
periods.append(row)
|
||||
elapsed += years
|
||||
return {
|
||||
"status": "calculated",
|
||||
"periods": periods,
|
||||
"sree_longitude": sree,
|
||||
"note_en": note,
|
||||
"reason_en": "No third level is provided for this Sudasa reading.",
|
||||
"reason_zh": "这一路 Sudasa 没有次小运。",
|
||||
"pd_status": "omitted",
|
||||
}
|
||||
|
||||
|
||||
def sudasa_confirmed_anchor() -> dict:
|
||||
"""No confirmed full major start is stored, so this branch has no table."""
|
||||
return {
|
||||
"status": "omitted",
|
||||
"periods": [],
|
||||
"reason_en": "The confirmed-start branch needs a written full major-period start. This report does not have that source, so no table is printed.",
|
||||
"reason_zh": "已确认起点这一支需要书面确认的完整主运起点。这次没有这份依据,所以不列表。",
|
||||
}
|
||||
|
||||
|
||||
def _varnada_one(lagna: int, hora: int, seed: int, longitudes: dict[str, float], epoch: datetime, year_days: float) -> dict:
|
||||
"""Guru Rajesh inclusive count. Child thirds are not established."""
|
||||
count_direction = -1 if lagna % 2 else 1
|
||||
origin = 11 if count_direction == -1 else 0
|
||||
lagna_count = ((lagna - origin) * count_direction) % 12 + 1
|
||||
hora_count = ((hora - origin) * count_direction) % 12 + 1
|
||||
same = lagna_count % 2 == hora_count % 2
|
||||
number = lagna_count + hora_count if same else abs(lagna_count - hora_count)
|
||||
varnada = (origin + count_direction * (number - 1)) % 12
|
||||
shift = ((varnada - lagna) * (1 if lagna % 2 == 0 else -1)) % 12 + 1
|
||||
signs = {name: int(float(longitudes[name]) // 30) for name in CLASSICAL}
|
||||
direction = 1 if seed % 2 == 0 else -1
|
||||
if any(signs[name] == varnada for name in ("Rahu", "Ketu")):
|
||||
direction *= -1
|
||||
start = _aware(epoch)
|
||||
age = 0.0
|
||||
periods = []
|
||||
for index in range(12):
|
||||
sign = (seed + direction * index) % 12
|
||||
step = 1 if sign % 2 == 0 else -1
|
||||
target = (sign + step * (shift - 1)) % 12
|
||||
lord = SIGN_LORDS[SIGNS[target]]
|
||||
duration_direction = 1 if target % 2 == 0 else -1
|
||||
distance = ((signs[lord] - target) * duration_direction) % 12
|
||||
years = 12 if distance == 0 else 10 if distance == 6 else distance + 1
|
||||
children = []
|
||||
for child in range(12):
|
||||
child_sign = (sign + step * child) % 12
|
||||
child_start = age + years * child / 12.0
|
||||
child_end = age + years * (child + 1) / 12.0
|
||||
children.append({
|
||||
"sign": SIGNS[child_sign],
|
||||
"lord": SIGN_LORDS[SIGNS[child_sign]],
|
||||
"level": "AD",
|
||||
"start": _at(start, child_start, year_days),
|
||||
"end": _at(start, child_end, year_days),
|
||||
})
|
||||
periods.append({
|
||||
"sign": SIGNS[sign],
|
||||
"lord": SIGN_LORDS[SIGNS[sign]],
|
||||
"level": "MD",
|
||||
"start": _at(start, age, year_days),
|
||||
"end": _at(start, age + years, year_days),
|
||||
"antardashas": children,
|
||||
})
|
||||
age += years
|
||||
return {"status": "calculated", "periods": periods, "seed_sign": SIGNS[seed]}
|
||||
|
||||
|
||||
def varnada_jss(lagna: int, hora: int, longitudes: dict[str, float], epoch: datetime, year_days: float) -> list[dict]:
|
||||
seeds = [lagna] if lagna == hora else [lagna, hora]
|
||||
rows = []
|
||||
for seed in seeds:
|
||||
built = _varnada_one(lagna, hora, seed, longitudes, epoch, year_days)
|
||||
built["reason_en"] = "The translation does not establish a third level."
|
||||
built["reason_zh"] = "这份译文没有建立次小运,所以这里只到子运。"
|
||||
built["pd_status"] = "omitted"
|
||||
rows.append(built)
|
||||
return rows
|
||||
|
||||
|
||||
def hora_sign_from_events(epoch: datetime, lagna_sign: int, events: list[dict]) -> dict:
|
||||
"""Day and night each have twelve horas. The sign origin is the lagna sign, not the exact degree."""
|
||||
start = _aware(epoch)
|
||||
parsed = []
|
||||
for event in events:
|
||||
if event.get("event") not in ("sunrise", "sunset"):
|
||||
continue
|
||||
parsed.append((event["event"], datetime.fromisoformat(event["datetime_utc"])))
|
||||
for (name, left), (_, right) in zip(parsed, parsed[1:]):
|
||||
if left >= right or not left <= start < right or (right - left).total_seconds() >= 86400:
|
||||
continue
|
||||
duration = (right - left) // timedelta(microseconds=1)
|
||||
elapsed = (start - left) // timedelta(microseconds=1)
|
||||
if duration <= 0:
|
||||
continue
|
||||
quotient, remainder = divmod(elapsed * 12, duration)
|
||||
direction = 1 if lagna_sign % 2 == 0 else -1
|
||||
sign = (lagna_sign + direction * int(quotient)) % 12
|
||||
return {"status": "calculated", "sign_index": sign, "segment": "day" if name == "sunrise" else "night"}
|
||||
return {
|
||||
"status": "omitted",
|
||||
"reason_en": "Sunrise and sunset do not bracket birth, so the hora sign is not listed.",
|
||||
"reason_zh": "日出日落没有框住出生时刻,时段上升星座这里不列表。",
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Sidereal Sun and Moon on the chart function's frame.
|
||||
|
||||
One process import. Ayanamsa is recomputed per instant so a year-long
|
||||
search does not freeze the precession. This is not the apparent path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import swisseph as swe
|
||||
|
||||
try:
|
||||
from jyotish_engine import _apply_ayanamsa, _sidereal_calculation_profile
|
||||
except ImportError: # pragma: no cover - package import
|
||||
from scripts.jyotish_engine import _apply_ayanamsa, _sidereal_calculation_profile
|
||||
|
||||
_SPEED = getattr(swe, "FLG_SPEED", 256)
|
||||
|
||||
|
||||
class SiderealFrame:
|
||||
def __init__(self, ayanamsa_name: str, node_mode: str = "mean") -> None:
|
||||
self.ayanamsa_name = str(ayanamsa_name or "lahiri")
|
||||
self.node_mode = node_mode if node_mode in ("mean", "true") else "mean"
|
||||
_apply_ayanamsa(self.ayanamsa_name)
|
||||
|
||||
def sun_moon(self, jd: float) -> tuple[float, float]:
|
||||
_mode, calc_flags, ayanamsa = _sidereal_calculation_profile(float(jd), "legacy")
|
||||
flags = calc_flags | _SPEED
|
||||
sun = _body(float(jd), swe.SUN, flags, ayanamsa)
|
||||
moon = _body(float(jd), swe.MOON, flags, ayanamsa)
|
||||
return sun, moon
|
||||
|
||||
|
||||
def _body(jd: float, body: int, flags: int, ayanamsa: float) -> float:
|
||||
pos, ret = swe.calc_ut(jd, body, flags)
|
||||
if ret < 0:
|
||||
raise RuntimeError(f"swisseph calc_ut failed for body {body} at jd {jd}: code {ret}")
|
||||
return (float(pos[0]) - float(ayanamsa)) % 360.0
|
||||
|
||||
|
||||
def birth_jd(year: int, month: int, day: int, hour: float, tz_hours: float) -> float:
|
||||
"""Same Julian day the chart function uses: local clock minus the offset."""
|
||||
return float(swe.julday(int(year), int(month), int(day), float(hour) - float(tz_hours)))
|
||||
|
||||
|
||||
def year_start_jd(year: int) -> float:
|
||||
return float(swe.julday(int(year), 1, 1, 0.0))
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Third-level rows for four rashi dashas, on a copy only.
|
||||
|
||||
The producer stays free of this key. Printed pages name the sub-period
|
||||
and not the third level. The split used here is the direct equal-time
|
||||
rule in the upstream calendar helper: the first child keeps the
|
||||
sub-period sign, the rest run forward, and the last child ends on the
|
||||
sub-period end. An empty sub-period list, including a Drig strength tie,
|
||||
gets no third level.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from datetime import datetime
|
||||
|
||||
try:
|
||||
from rashi_dashas_native import SIGNS
|
||||
except ImportError: # pragma: no cover - package import
|
||||
from scripts.rashi_dashas_native import SIGNS
|
||||
|
||||
FAMILIES = ("sthira", "drig", "shoola", "navamsha")
|
||||
|
||||
|
||||
def attach_rashi_pratyantar(calculated: dict) -> dict:
|
||||
"""Return a deep copy with third-level rows. The input is not changed."""
|
||||
copied = deepcopy(calculated)
|
||||
for name in FAMILIES:
|
||||
section = copied.get(name)
|
||||
if not isinstance(section, dict):
|
||||
continue
|
||||
for major in section.get("periods") or []:
|
||||
if not isinstance(major, dict):
|
||||
continue
|
||||
children = major.get("antardasha")
|
||||
if not isinstance(children, list) or not children:
|
||||
continue
|
||||
for child in children:
|
||||
if not isinstance(child, dict):
|
||||
continue
|
||||
third = _equal_forward(child)
|
||||
if third:
|
||||
child["pratyantar"] = third
|
||||
return copied
|
||||
|
||||
|
||||
def _equal_forward(child: dict) -> list[dict]:
|
||||
sign = child.get("sign_index")
|
||||
if not isinstance(sign, int):
|
||||
label = child.get("sign")
|
||||
if label not in SIGNS:
|
||||
return []
|
||||
sign = SIGNS.index(label)
|
||||
try:
|
||||
start = datetime.fromisoformat(str(child["start"]))
|
||||
end = datetime.fromisoformat(str(child["end"]))
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return []
|
||||
total = (end - start).total_seconds()
|
||||
if total <= 0:
|
||||
return []
|
||||
rows = []
|
||||
for offset in range(12):
|
||||
moment = end if offset == 11 else start + _seconds(total * (offset + 1) / 12.0)
|
||||
opened = start if offset == 0 else start + _seconds(total * offset / 12.0)
|
||||
index = (int(sign) + offset) % 12
|
||||
rows.append({
|
||||
"sign": SIGNS[index],
|
||||
"sign_index": index,
|
||||
"start": opened.date().isoformat(),
|
||||
"end": moment.date().isoformat(),
|
||||
"level": "PD",
|
||||
})
|
||||
rows[-1]["end"] = end.date().isoformat()
|
||||
return rows
|
||||
|
||||
|
||||
def _seconds(value: float):
|
||||
from datetime import timedelta
|
||||
return timedelta(seconds=value)
|
||||
@@ -0,0 +1,500 @@
|
||||
"""Snake-free bilingual chapters for the research appendix.
|
||||
|
||||
The chapters are computed while the report is rendered. They are not stored
|
||||
on the packet, and they are not sent to chat cards or rectification.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from report_render.parity_chapters import _first, _major_list
|
||||
from report_render.text import PLANET_LABEL_CN, SIGNS_CN
|
||||
from research_timing.bundle import chart_from_parts, research_bundle
|
||||
from research_timing.rashi_pd import attach_rashi_pratyantar
|
||||
from rashi_dashas_native import calculate_rashi_dashas
|
||||
except ImportError: # pragma: no cover - package import
|
||||
from scripts.report_render.parity_chapters import _first, _major_list
|
||||
from scripts.report_render.text import PLANET_LABEL_CN, SIGNS_CN
|
||||
from scripts.research_timing.bundle import chart_from_parts, research_bundle
|
||||
from scripts.research_timing.rashi_pd import attach_rashi_pratyantar
|
||||
from scripts.rashi_dashas_native import calculate_rashi_dashas
|
||||
|
||||
try:
|
||||
from annual_native.dasha_children import attach_kalachakra_simplified_children
|
||||
except ImportError: # pragma: no cover - package import
|
||||
from scripts.annual_native.dasha_children import attach_kalachakra_simplified_children
|
||||
|
||||
BANNER = {
|
||||
"zh": "研究口径:依赖所选方法,未经外部软件核对。",
|
||||
"en": "Research basis: method-dependent; not checked against external software.",
|
||||
}
|
||||
_CHILD_KEYS = ("antardashas", "pratyantardashas", "antardasha", "pratyantar", "pratyantardasha")
|
||||
_LEVELS = {"MD": "MD", "AD": "AD", "PD": "PD", "antardasha": "AD", "pratyantardasha": "PD", "pratyantar": "PD"}
|
||||
_FIXED = {
|
||||
"tithi-natal-forward": ("Natal Tithi Ashtottari forward", "本命 Tithi Ashtottari 顺行"),
|
||||
"tithi-natal-reverse": ("Natal Tithi Ashtottari reverse", "本命 Tithi Ashtottari 逆行"),
|
||||
"yoga-natal": ("Natal Yoga Vimshottari", "本命 Yoga Vimshottari"),
|
||||
"saptavarga": ("Saptavarga lords", "七分盘星座主"),
|
||||
"buddhi-gati": ("Buddhi Gati", "Buddhi Gati"),
|
||||
"kaala": ("Kaala", "Kaala"),
|
||||
"sudasa-confirmed": ("Sudasa confirmed start", "Sudasa 已确认起点"),
|
||||
"sudasa-birth": ("Sudasa birth cycle", "Sudasa 出生周期"),
|
||||
"varnada": ("Varnada", "Varnada"),
|
||||
"karana": ("Karana year", "Karana 年"),
|
||||
}
|
||||
_TECH = {"tithi": "Tithi Ashtottari", "yoga": "Yoga Pravesha", "nakshatra": "Nakshatra Pravesha"}
|
||||
_KIND = {
|
||||
"en": {
|
||||
"arc": "arc balance", "time": "elapsed balance", "successor": "successor arc",
|
||||
"tithi-year": "360 tithi year", "forward": "forward", "reverse": "reverse",
|
||||
},
|
||||
"zh": {
|
||||
"arc": "角距", "time": "时间余额", "successor": "后继角距",
|
||||
"tithi-year": "360 太阴月年", "forward": "顺行", "reverse": "逆行",
|
||||
},
|
||||
}
|
||||
_KARANA = {
|
||||
"julene": ("Julene lords", "Julene 主星"),
|
||||
"pvr": ("PVR quadrant lords", "PVR 象限主星"),
|
||||
"pvr-tithi-year": ("PVR 360 tithi year", "PVR 360 太阴月年"),
|
||||
}
|
||||
_RASHI = (
|
||||
("sthira", "Sthira", "Sthira"),
|
||||
("drig", "Drig", "Drig"),
|
||||
("shoola", "Shoola", "Shoola"),
|
||||
("navamsha", "Navamsha", "Navamsha"),
|
||||
)
|
||||
_DIVISION = {1: "D1", 2: "D2", 3: "D3", 7: "D7", 9: "D9", 12: "D12", 30: "D30", 81: "D81", 108: "D108", 144: "D144", 150: "D150"}
|
||||
|
||||
|
||||
def render_research_chapters(packet: dict, language: str) -> str:
|
||||
"""Three research chapters. One failed family does not remove the others."""
|
||||
if language not in ("zh", "en"):
|
||||
raise ValueError("language must be zh or en")
|
||||
try:
|
||||
chart = context_from_packet(packet if isinstance(packet, dict) else {})
|
||||
bundle = research_bundle(chart)
|
||||
except (ValueError, RuntimeError, KeyError, TypeError) as exc:
|
||||
logger.warning("research timing chapter unavailable: %s: %s", type(exc).__name__, exc)
|
||||
text = _chapter(
|
||||
"Additional timing calculations" if language == "en" else "补充推运计算数据",
|
||||
language,
|
||||
_reason(language, "This chapter did not finish. The runtime log has the cause.", "这一章没有算成,原因记在运行日志里。"),
|
||||
)
|
||||
return text
|
||||
timing = _timing_chapter(bundle, language)
|
||||
auxiliary = _auxiliary_chapter(packet, chart, language)
|
||||
vargas = _varga_chapter(bundle, language)
|
||||
return "\n".join(part for part in (timing, auxiliary, vargas) if part)
|
||||
|
||||
|
||||
def context_from_packet(packet: dict) -> dict:
|
||||
birth = packet.get("birth_info") if isinstance(packet.get("birth_info"), dict) else {}
|
||||
profile = packet.get("calculation_profile") if isinstance(packet.get("calculation_profile"), dict) else {}
|
||||
settings = profile.get("effective_settings") if isinstance(profile.get("effective_settings"), dict) else {}
|
||||
year, month, day = _birth_date(birth)
|
||||
hour = int(birth.get("hour") if birth.get("hour") is not None else 0)
|
||||
minute = int(birth.get("minute") if birth.get("minute") is not None else 0)
|
||||
second = int(birth.get("second") if birth.get("second") is not None else 0)
|
||||
if birth.get("lat") is None and birth.get("latitude") is None:
|
||||
raise ValueError("birth place is missing")
|
||||
if birth.get("lon") is None and birth.get("longitude") is None:
|
||||
raise ValueError("birth place is missing")
|
||||
lat = float(birth.get("lat") if birth.get("lat") is not None else birth.get("latitude"))
|
||||
lon = float(birth.get("lon") if birth.get("lon") is not None else birth.get("longitude"))
|
||||
tz = _tz(settings.get("timezone_offset") if settings.get("timezone_offset") not in (None, "") else birth.get("tz", birth.get("timezone")))
|
||||
ayanamsa = str(birth.get("ayanamsa_name") or birth.get("ayanamsa") or settings.get("ayanamsa") or "lahiri")
|
||||
node_mode = str(birth.get("node_mode") or settings.get("node_mode") or "mean")
|
||||
year_days = float(settings.get("dasha_year_days") or 365.25)
|
||||
target_year, query = _query(packet, year, tz)
|
||||
return chart_from_parts(
|
||||
year=year, month=month, day=day, hour=hour, minute=minute, second=second,
|
||||
lat=lat, lon=lon, tz=tz, ayanamsa=ayanamsa, node_mode=node_mode,
|
||||
target_year=target_year, query=query, year_days=year_days,
|
||||
)
|
||||
|
||||
|
||||
def _timing_chapter(bundle: dict, language: str) -> str:
|
||||
parts = [_chapter("Additional timing calculations" if language == "en" else "补充推运计算数据", language, "")]
|
||||
note = bundle["reference_note_en"] if language == "en" else bundle["reference_note_zh"]
|
||||
counts = _return_counts(bundle)
|
||||
count_line = (
|
||||
f"Tithi returns across the target year and the next two years: {counts['tithi']}. "
|
||||
f"Yoga returns in the target year: {counts['yoga']}. "
|
||||
f"Nakshatra returns in the target year: {counts['nakshatra']}."
|
||||
if language == "en"
|
||||
else (
|
||||
f"目标年及后两年的 Tithi 回归次数:{counts['tithi']}。"
|
||||
f"目标年的 Yoga 回归次数:{counts['yoga']}。"
|
||||
f"目标年的 Nakshatra 回归次数:{counts['nakshatra']}。"
|
||||
)
|
||||
)
|
||||
parts.append(note + "\n\n" + count_line)
|
||||
families = bundle.get("families") or {}
|
||||
for key, family in families.items():
|
||||
if key in ("annual-returns", "research-vargas"):
|
||||
continue
|
||||
if key == "karana":
|
||||
groups = family.get("returns") if isinstance(family, dict) else None
|
||||
if isinstance(groups, list):
|
||||
for group in groups:
|
||||
number = group.get("index") or ""
|
||||
suffix = f"return {number}" if language == "en" else f"第 {number} 次"
|
||||
parts.extend(_variant_sections(group, language, "Karana", suffix))
|
||||
else:
|
||||
parts.extend(_variant_sections(family, language, "Karana", ""))
|
||||
continue
|
||||
if key == "varnada" and isinstance(family.get("variants"), list):
|
||||
parts.extend(_variant_sections(family, language, "Varnada"))
|
||||
continue
|
||||
title = _heading(key, language)
|
||||
if not title:
|
||||
continue
|
||||
parts.append(_family_section(title, family, language, bundle.get("query")))
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
def _auxiliary_chapter(packet: dict, chart: dict, language: str) -> str:
|
||||
parts = [_chapter("Auxiliary dasha subperiods" if language == "en" else "辅助大运次小运数据", language, "")]
|
||||
birth = datetime(chart["year"], chart["month"], chart["day"]).date()
|
||||
longitudes = chart["longitudes"]
|
||||
original = calculate_rashi_dashas(birth, chart["ascendant"], longitudes)
|
||||
copied = attach_rashi_pratyantar(original)
|
||||
rule = (
|
||||
"The third level starts at the sub-period sign and runs forward, splitting that sub-period into twelve equal parts. Major and sub-period rows stay as they were."
|
||||
if language == "en"
|
||||
else "次小运从子运星座起顺行,把该子运的时间等分成十二段。主运和子运与原来的表相同。"
|
||||
)
|
||||
for key, en, zh in _RASHI:
|
||||
title = en if language == "en" else zh
|
||||
section = copied.get(key) if isinstance(copied.get(key), dict) else {}
|
||||
periods = _third_only(section.get("periods") or [])
|
||||
body = rule + "\n\n" + (_table(periods, language) if periods else _reason(
|
||||
language,
|
||||
"This system has no sub-period rows, so the third level is not listed.",
|
||||
"这个体系没有子运,所以不列次小运。",
|
||||
))
|
||||
parts.append(_family_block(title, language, body))
|
||||
niryaana = (
|
||||
"Niryaana Shoola child periods and the third level are omitted. The upstream program states that the child-period rule is not independently specified. Rows from an unpublished frozen engine are not a written source."
|
||||
if language == "en"
|
||||
else "Niryaana Shoola 的子运和次小运这次不列。上游主程序写明子运规则没有单独的出处,冻结引擎里的几行不能当作书页依据。"
|
||||
)
|
||||
parts.append(_family_block("Niryaana Shoola", language, niryaana))
|
||||
parts.append(_family_block("Kalachakra", language, _kalachakra(packet, language)))
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
def _varga_chapter(bundle: dict, language: str) -> str:
|
||||
family = (bundle.get("families") or {}).get("research-vargas") or {}
|
||||
rows = family.get("rows") or []
|
||||
intro = (
|
||||
"D81, D108 and D144 are printed only in this chapter."
|
||||
if language == "en"
|
||||
else "D81、D108、D144 只出现在这一章。"
|
||||
)
|
||||
body = intro if family.get("status") == "calculated" else _text(family, language)
|
||||
if rows:
|
||||
body += "\n\n" + _varga_table(rows, language)
|
||||
return _chapter("Research divisional charts" if language == "en" else "研究型分盘", language, body)
|
||||
|
||||
|
||||
def _variant_sections(family: dict, language: str, fallback: str, suffix: str = "") -> list[str]:
|
||||
variants = family.get("variants")
|
||||
if not isinstance(variants, list) or not variants:
|
||||
return [_family_block(fallback, language, _text(family, language))]
|
||||
sections = []
|
||||
for index, variant in enumerate(variants, start=1):
|
||||
profile = variant.get("profile")
|
||||
if profile in _KARANA:
|
||||
title = _KARANA[profile][0 if language == "en" else 1]
|
||||
else:
|
||||
seed = variant.get("seed_sign")
|
||||
title = f"{fallback} {_name(seed, language)}".strip() if seed else f"{fallback} {index}"
|
||||
if suffix:
|
||||
title = f"{title} {suffix}"
|
||||
sections.append(_family_section(title, variant, language, None))
|
||||
return sections
|
||||
|
||||
|
||||
def _family_section(title: str, family: dict, language: str, query: str | None) -> str:
|
||||
body = [_text(family, language)]
|
||||
periods = family.get("periods") or []
|
||||
if periods and family.get("status") != "omitted":
|
||||
if query:
|
||||
current = _current(periods, query)
|
||||
if current:
|
||||
label = "Reference row" if language == "en" else "参考时刻所在"
|
||||
body.append(label + "\n\n" + _table(current, language))
|
||||
body.append(_table(periods, language))
|
||||
rows = family.get("rows")
|
||||
if rows:
|
||||
body.append(_varga_table(rows, language))
|
||||
return _family_block(title, language, "\n\n".join(part for part in body if part))
|
||||
|
||||
|
||||
def _third_only(periods: list[dict]) -> list[dict]:
|
||||
rows = []
|
||||
for major in periods:
|
||||
if not isinstance(major, dict):
|
||||
continue
|
||||
for child in major.get("antardasha") or []:
|
||||
third = child.get("pratyantar") if isinstance(child, dict) else None
|
||||
if isinstance(third, list) and third:
|
||||
rows.extend(third)
|
||||
return rows
|
||||
|
||||
|
||||
def _kalachakra(packet: dict, language: str) -> str:
|
||||
note = (
|
||||
"The third level uses the simplified model, split in proportion from the majors already on the chart. Major and sub-period rows stay as they were."
|
||||
if language == "en"
|
||||
else "次小运按简化模型,从现有主运按比例分出。主运和子运与原来的表相同。"
|
||||
)
|
||||
module = _first(packet, ("timing_and_predictive_systems", "kalachakra_dasha"))
|
||||
rows = [item for item in _major_list(module) if isinstance(item, dict) and "antardasha" not in item and "subperiods" not in item]
|
||||
if not rows:
|
||||
rows = [item for item in _major_list(module) if isinstance(item, dict)]
|
||||
if not rows:
|
||||
missing = "The chart has no Kalachakra majors, so the third level is not listed." if language == "en" else "这张盘没有 Kalachakra 主运,所以不列次小运。"
|
||||
return note + "\n\n" + missing
|
||||
try:
|
||||
_enriched, _children, grands = attach_kalachakra_simplified_children(rows)
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
logger.warning("research kalachakra third level failed: %s: %s", type(exc).__name__, exc)
|
||||
failed = "The third level did not finish. The runtime log has the cause." if language == "en" else "次小运没有算成,原因记在运行日志里。"
|
||||
return note + "\n\n" + failed
|
||||
shaped = [{
|
||||
"level": "PD",
|
||||
"sign": item.get("rashi") or item.get("sign"),
|
||||
"lord": item.get("lord"),
|
||||
"start": item.get("start_date") or item.get("start"),
|
||||
"end": item.get("end_date") or item.get("end"),
|
||||
} for item in grands if isinstance(item, dict)]
|
||||
if not shaped:
|
||||
empty = "No third-level rows were produced." if language == "en" else "没有产生次小运行。"
|
||||
return note + "\n\n" + empty
|
||||
return note + "\n\n" + _table(shaped, language)
|
||||
|
||||
|
||||
def _chapter(title: str, language: str, body: str) -> str:
|
||||
text = f"## {title}\n\n{BANNER[language]}"
|
||||
if body:
|
||||
text += "\n\n" + body.strip()
|
||||
return text
|
||||
|
||||
|
||||
def _family_block(title: str, language: str, body: str) -> str:
|
||||
return f"## {title}\n\n{BANNER[language]}\n\n{body.strip()}"
|
||||
|
||||
|
||||
def _heading(key: str, language: str) -> str:
|
||||
if key in _FIXED:
|
||||
en, zh = _FIXED[key]
|
||||
return en if language == "en" else zh
|
||||
matched = re.fullmatch(r"tithi-(\d{4})-(\d+)-pair-(\d+)-(arc|time)-(forward|reverse)", key)
|
||||
if matched:
|
||||
year, number, pair, kind, direction = matched.groups()
|
||||
if language == "zh":
|
||||
return (
|
||||
f"年度 Tithi Ashtottari {_KIND['zh'][kind]} {_KIND['zh'][direction]} "
|
||||
f"{year} 年第 {number} 次,配对 {pair}"
|
||||
)
|
||||
return (
|
||||
f"Annual Tithi Ashtottari {_KIND['en'][kind]} {_KIND['en'][direction]} "
|
||||
f"{year} return {number} pair {pair}"
|
||||
)
|
||||
matched = re.fullmatch(r"tithi-(\d{4})-(\d+)", key)
|
||||
if matched:
|
||||
year, number = matched.groups()
|
||||
if language == "zh":
|
||||
return f"年度 Tithi Ashtottari {year} 年第 {number} 次"
|
||||
return f"Annual Tithi Ashtottari {year} return {number}"
|
||||
matched = re.fullmatch(r"(yoga|nakshatra)-(\d+)-successor-(\d+)", key)
|
||||
if matched:
|
||||
tech, number, pair = matched.groups()
|
||||
if language == "zh":
|
||||
return f"年度 {_TECH[tech]} 后继角距 第 {number} 次,配对 {pair}"
|
||||
return f"Annual {_TECH[tech]} successor arc return {number} pair {pair}"
|
||||
matched = re.fullmatch(r"(yoga|nakshatra)-(\d+)-tithi-year", key)
|
||||
if matched:
|
||||
tech, number = matched.groups()
|
||||
if language == "zh":
|
||||
return f"年度 {_TECH[tech]} 360 太阴月年 第 {number} 次"
|
||||
return f"Annual {_TECH[tech]} 360 tithi year return {number}"
|
||||
matched = re.fullmatch(r"(yoga|nakshatra)-(\d+)-successor", key)
|
||||
if matched:
|
||||
tech, number = matched.groups()
|
||||
if language == "zh":
|
||||
return f"年度 {_TECH[tech]} 后继角距 第 {number} 次"
|
||||
return f"Annual {_TECH[tech]} successor arc return {number}"
|
||||
return ""
|
||||
|
||||
|
||||
def _text(family: dict, language: str) -> str:
|
||||
if not isinstance(family, dict):
|
||||
return ""
|
||||
custom = family.get("reason_en") if language == "en" else family.get("reason_zh")
|
||||
if custom:
|
||||
return str(custom)
|
||||
if family.get("pd_status") in ("omitted", "blocked"):
|
||||
return _reason(
|
||||
language,
|
||||
"No third level is listed for this method.",
|
||||
"这个方法这里不列次小运。",
|
||||
)
|
||||
if family.get("status") == "omitted":
|
||||
return _reason(language, "This branch is not listed.", "这一支不列表。")
|
||||
return ""
|
||||
|
||||
|
||||
def _reason(language: str, en: str, zh: str) -> str:
|
||||
return en if language == "en" else zh
|
||||
|
||||
|
||||
def _table(periods: list[dict], language: str) -> str:
|
||||
headers = ("Level", "Planet or name", "Start", "End") if language == "en" else ("层级", "主星/名称", "起始", "结束")
|
||||
lines = ["| " + " | ".join(headers) + " |", "| --- | --- | --- | --- |"]
|
||||
|
||||
def walk(rows: list, fallback: str) -> None:
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
level = _LEVELS.get(str(row.get("level") or fallback), fallback)
|
||||
label = "MD" if language == "en" else "主运"
|
||||
if level == "AD":
|
||||
label = "AD" if language == "en" else "子运"
|
||||
elif level == "PD":
|
||||
label = "PD" if language == "en" else "次小运"
|
||||
name = row.get("lord") or row.get("planet") or row.get("sign") or ""
|
||||
lines.append("| " + " | ".join((
|
||||
label,
|
||||
_name(name, language),
|
||||
_plain(row.get("start")),
|
||||
_plain(row.get("end")),
|
||||
)) + " |")
|
||||
for key in _CHILD_KEYS:
|
||||
children = row.get(key)
|
||||
if isinstance(children, list) and children:
|
||||
walk(children, "AD" if level == "MD" else "PD")
|
||||
|
||||
walk(periods, "MD")
|
||||
if len(lines) <= 2:
|
||||
return ""
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _current(periods: list[dict], query: str) -> list[dict]:
|
||||
moment = datetime.fromisoformat(query)
|
||||
found: list[dict] = []
|
||||
|
||||
def walk(rows: list) -> bool:
|
||||
for row in rows:
|
||||
if not isinstance(row, dict) or "start" not in row or "end" not in row:
|
||||
continue
|
||||
try:
|
||||
start = _align(datetime.fromisoformat(str(row["start"])), moment)
|
||||
end = _align(datetime.fromisoformat(str(row["end"])), moment)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if start <= moment < end:
|
||||
found.append({key: row.get(key) for key in ("level", "lord", "planet", "sign", "start", "end")})
|
||||
for key in _CHILD_KEYS:
|
||||
children = row.get(key)
|
||||
if isinstance(children, list) and walk(children):
|
||||
break
|
||||
return True
|
||||
return False
|
||||
|
||||
walk(periods)
|
||||
return found
|
||||
|
||||
|
||||
def _align(value: datetime, sample: datetime) -> datetime:
|
||||
if value.utcoffset() is None and sample.utcoffset() is not None:
|
||||
return value.replace(tzinfo=sample.tzinfo)
|
||||
if value.utcoffset() is not None and sample.utcoffset() is None:
|
||||
return value.replace(tzinfo=None)
|
||||
return value
|
||||
|
||||
|
||||
def _varga_table(rows: list[dict], language: str) -> str:
|
||||
headers = ("Division", "Body", "Sign", "Degree", "Sign lord") if language == "en" else ("分盘", "星体", "星座", "度数", "星座主")
|
||||
lines = ["| " + " | ".join(headers) + " |", "| --- | --- | --- | --- | --- |"]
|
||||
for row in rows:
|
||||
division = _DIVISION.get(int(row.get("division") or 0), str(row.get("division") or ""))
|
||||
degree = row.get("degree")
|
||||
degree_text = f"{float(degree):.4f}" if isinstance(degree, (int, float)) else ""
|
||||
lines.append("| " + " | ".join((
|
||||
division,
|
||||
_name(row.get("body"), language),
|
||||
_name(row.get("sign"), language),
|
||||
degree_text,
|
||||
_name(row.get("lord"), language),
|
||||
)) + " |")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _name(value, language: str) -> str:
|
||||
text = "" if value is None else str(value)
|
||||
if language == "zh":
|
||||
text = PLANET_LABEL_CN.get(text, SIGNS_CN.get(text, text))
|
||||
if text == "Lagna":
|
||||
text = "上升"
|
||||
return _plain(text)
|
||||
|
||||
|
||||
def _plain(value) -> str:
|
||||
return str(value or "").replace("|", "/").replace("\n", " ")
|
||||
|
||||
|
||||
def _return_counts(bundle: dict) -> dict[str, int]:
|
||||
found = ((bundle.get("families") or {}).get("annual-returns") or {}).get("returns") or {}
|
||||
return {key: len(found.get(key) or []) for key in ("tithi", "yoga", "nakshatra")}
|
||||
|
||||
|
||||
def _birth_date(birth: dict) -> tuple[int, int, int]:
|
||||
if all(birth.get(key) is not None for key in ("year", "month", "day")):
|
||||
return int(birth["year"]), int(birth["month"]), int(birth["day"])
|
||||
text = str(birth.get("date") or "")
|
||||
year, month, day = text.split("-")[:3]
|
||||
return int(year), int(month), int(day)
|
||||
|
||||
|
||||
def _tz(value) -> float:
|
||||
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
||||
return float(value)
|
||||
text = str(value or "").strip().upper().replace("UTC", "")
|
||||
matched = re.fullmatch(r"([+-])(\d{2}):(\d{2})", text)
|
||||
if matched:
|
||||
sign = 1 if matched.group(1) == "+" else -1
|
||||
return sign * (int(matched.group(2)) + int(matched.group(3)) / 60.0)
|
||||
if not text:
|
||||
raise ValueError("timezone is missing")
|
||||
return float(text)
|
||||
|
||||
|
||||
def _query(packet: dict, birth_year: int, tz: float) -> tuple[int, datetime]:
|
||||
pack = _first(packet, ("timing_and_predictive_systems", "annual_tajika_pack"))
|
||||
profile = pack.get("profile") if isinstance(pack, dict) else {}
|
||||
target = profile.get("target_year") if isinstance(profile, dict) else None
|
||||
if target in (None, ""):
|
||||
target = packet.get("birth_info", {}).get("target_year") if isinstance(packet.get("birth_info"), dict) else None
|
||||
ephemeris = _first(packet, ("timing_and_predictive_systems", "report_month_ephemeris"))
|
||||
rows = ephemeris.get("report_rows") if isinstance(ephemeris, dict) else None
|
||||
month_text = rows[0].get("month") if isinstance(rows, list) and rows and isinstance(rows[0], dict) else None
|
||||
zone = timezone(timedelta(hours=tz))
|
||||
if isinstance(month_text, str) and len(month_text) >= 7 and month_text[4] == "-":
|
||||
year = int(month_text[0:4])
|
||||
month = int(month_text[5:7])
|
||||
if target in (None, ""):
|
||||
target = year
|
||||
return int(target), datetime(year, month, 1, 12, 0, tzinfo=zone)
|
||||
year = int(target if target not in (None, "") else birth_year)
|
||||
return year, datetime(year, 1, 1, 12, 0, tzinfo=zone)
|
||||
@@ -0,0 +1,254 @@
|
||||
"""Return roots and elongation clocks on SiderealFrame.
|
||||
|
||||
The step and bisection follow the research return search. The longitude
|
||||
call is ours. Windows stay at most 400 days.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import bisect
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import swisseph as swe
|
||||
|
||||
try:
|
||||
from research_timing.frame import SiderealFrame
|
||||
except ImportError: # pragma: no cover - package import
|
||||
from scripts.research_timing.frame import SiderealFrame
|
||||
|
||||
try:
|
||||
from muhurta import _find_next_boundary
|
||||
except ImportError: # pragma: no cover - package import
|
||||
from scripts.muhurta import _find_next_boundary
|
||||
|
||||
_STEP = 0.125
|
||||
_BISECTIONS = 45
|
||||
_TOLERANCE = 1e-6
|
||||
|
||||
|
||||
def find_returns(
|
||||
frame: SiderealFrame,
|
||||
technique: str,
|
||||
birth_jd: float,
|
||||
start_jd: float,
|
||||
end_jd: float,
|
||||
) -> dict:
|
||||
if technique not in ("yoga_pravesha", "nakshatra_pravesha", "tithi_pravesha"):
|
||||
raise ValueError(f"unsupported return technique: {technique}")
|
||||
if not start_jd < end_jd or end_jd - start_jd > 400:
|
||||
raise ValueError("search window must be positive and no longer than 400 days")
|
||||
sun, moon = frame.sun_moon(birth_jd)
|
||||
solar_sign = int(sun // 30)
|
||||
target = _coordinate(technique, sun, moon)
|
||||
|
||||
def residual(jd: float) -> float:
|
||||
left, right = frame.sun_moon(jd)
|
||||
value = _coordinate(technique, left, right)
|
||||
return (value - target + 180.0) % 360.0 - 180.0
|
||||
|
||||
rows: list[dict] = []
|
||||
left = float(start_jd)
|
||||
left_value = residual(left)
|
||||
while left < end_jd:
|
||||
right = min(left + _STEP, float(end_jd))
|
||||
right_value = residual(right)
|
||||
if left_value * right_value <= 0 and abs(right_value - left_value) < 180:
|
||||
root = left if abs(left_value) < 1e-8 else _bisect(left, right, left_value, residual)
|
||||
body_sun, body_moon = frame.sun_moon(root)
|
||||
error = residual(root)
|
||||
if (
|
||||
start_jd <= root < end_jd
|
||||
and abs(error) < _TOLERANCE
|
||||
and int(body_sun // 30) == solar_sign
|
||||
and all(abs(root - row["jd_ut"]) > _TOLERANCE for row in rows)
|
||||
):
|
||||
rows.append({
|
||||
"jd_ut": root,
|
||||
"sun_longitude": body_sun,
|
||||
"moon_longitude": body_moon,
|
||||
"residual_degrees": error,
|
||||
})
|
||||
left, left_value = right, right_value
|
||||
return {
|
||||
"technique": technique,
|
||||
"target_degrees": target,
|
||||
"natal_solar_sign_index": solar_sign,
|
||||
"natal_sun": sun,
|
||||
"natal_moon": moon,
|
||||
"returns": rows,
|
||||
"frame": "swiss_ephemeris_true_precession",
|
||||
}
|
||||
|
||||
|
||||
def _coordinate(technique: str, sun: float, moon: float) -> float:
|
||||
if technique == "tithi_pravesha":
|
||||
return (moon - sun) % 360.0
|
||||
if technique == "yoga_pravesha":
|
||||
return (sun + moon) % 360.0
|
||||
return moon % 360.0
|
||||
|
||||
|
||||
def _bisect(left: float, right: float, left_value: float, residual) -> float:
|
||||
low, high, low_value = left, right, left_value
|
||||
for _ in range(_BISECTIONS):
|
||||
mid = (low + high) / 2.0
|
||||
value = residual(mid)
|
||||
if low_value * value <= 0:
|
||||
high = mid
|
||||
else:
|
||||
low, low_value = mid, value
|
||||
return (low + high) / 2.0
|
||||
|
||||
|
||||
def jd_to_utc(jd: float) -> datetime:
|
||||
year, month, day, hour = swe.revjul(float(jd), swe.GREG_CAL)
|
||||
return datetime(int(year), int(month), int(day), tzinfo=timezone.utc) + timedelta(hours=float(hour))
|
||||
|
||||
|
||||
def containing_tithi(frame: SiderealFrame, jd: float) -> dict:
|
||||
"""Time fraction left in the 12-degree tithi that contains this instant."""
|
||||
|
||||
def phase(at: float) -> float:
|
||||
sun, moon = frame.sun_moon(at)
|
||||
return (moon - sun) % 360.0
|
||||
|
||||
angle = phase(jd)
|
||||
cursor = jd - 1.6
|
||||
previous = None
|
||||
following = None
|
||||
for _ in range(4):
|
||||
boundary = _find_next_boundary(cursor, phase, 12.0, 1.6)
|
||||
if boundary is None or boundary <= cursor:
|
||||
raise RuntimeError("containing tithi boundaries unavailable")
|
||||
if boundary > jd:
|
||||
following = boundary
|
||||
break
|
||||
previous, cursor = boundary, boundary
|
||||
if previous is None or following is None or not previous <= jd < following:
|
||||
raise RuntimeError("containing tithi boundaries unavailable")
|
||||
return {
|
||||
"tithi_num": int(angle // 12) + 1,
|
||||
"elongation": angle,
|
||||
"remaining_time_fraction": (following - jd) / (following - previous),
|
||||
"remaining_angular_fraction": 1.0 - (angle % 12.0) / 12.0,
|
||||
"start_jd_ut": previous,
|
||||
"end_jd_ut": following,
|
||||
}
|
||||
|
||||
|
||||
def measure_elongation(frame: SiderealFrame, start_jd: float, end_jd: float) -> float:
|
||||
"""Unwrapped sidereal Moon-minus-Sun over at most 400 days."""
|
||||
if not 0 < end_jd - start_jd <= 400:
|
||||
raise ValueError("motion interval must span more than 0 and at most 400 days")
|
||||
cursor = float(start_jd)
|
||||
sun, moon = frame.sun_moon(cursor)
|
||||
previous = (moon - sun) % 360.0
|
||||
total = 0.0
|
||||
while cursor < end_jd:
|
||||
cursor = min(cursor + 1.0, float(end_jd))
|
||||
sun, moon = frame.sun_moon(cursor)
|
||||
current = (moon - sun) % 360.0
|
||||
total += (current - previous + 180.0) % 360.0 - 180.0
|
||||
previous = current
|
||||
return total
|
||||
|
||||
|
||||
class ElongationClock:
|
||||
"""Map a 360-unit year onto accumulated sidereal elongation."""
|
||||
|
||||
def __init__(self, frame: SiderealFrame, return_jd: float, total_angle: float, known_end_jd: float | None = None) -> None:
|
||||
if not 0 < total_angle <= 6000:
|
||||
raise ValueError("positive total angle within the annual grid is required")
|
||||
self.frame = frame
|
||||
self.return_jd = float(return_jd)
|
||||
self.total_angle = float(total_angle)
|
||||
self._days = [self.return_jd + index for index in range(-100, 501)]
|
||||
self._phases = [self._phase(day) for day in self._days]
|
||||
self._angles = [0.0]
|
||||
for left, right in zip(self._phases, self._phases[1:]):
|
||||
step = (right - left + 180.0) % 360.0 - 180.0
|
||||
if not 0 < step < 180:
|
||||
raise ValueError("elongation clock requires forward daily motion")
|
||||
self._angles.append(self._angles[-1] + step)
|
||||
self._origin = self._angles[100]
|
||||
self._cache = {0.0: self.return_jd}
|
||||
if known_end_jd is not None:
|
||||
if not self.return_jd < known_end_jd <= self.return_jd + 500:
|
||||
raise ValueError("known endpoint must lie in the forward annual grid")
|
||||
self._cache[360.0] = float(known_end_jd)
|
||||
|
||||
def _phase(self, jd: float) -> float:
|
||||
sun, moon = self.frame.sun_moon(jd)
|
||||
return (moon - sun) % 360.0
|
||||
|
||||
def solve(self, offset: float) -> float:
|
||||
if abs(offset) < 1e-8:
|
||||
offset = 0.0
|
||||
if abs(offset - 360.0) < 1e-8:
|
||||
offset = 360.0
|
||||
cached = self._cache.get(offset)
|
||||
if cached is not None:
|
||||
return cached
|
||||
target = self._origin + offset / 360.0 * self.total_angle
|
||||
index = bisect.bisect_right(self._angles, target) - 1
|
||||
if not 0 <= index < len(self._days) - 1:
|
||||
raise ValueError("angular boundary outside the bounded search grid")
|
||||
found = self._solve_day(index, target)
|
||||
self._cache[offset] = found
|
||||
return found
|
||||
|
||||
def _solve_day(self, index: int, target: float) -> float:
|
||||
"""Day bracket, then a few slope steps. Residual stays within 1e-6 degree."""
|
||||
left, right = self._days[index], self._days[index + 1]
|
||||
left_angle = self._angles[index]
|
||||
span = self._angles[index + 1] - left_angle
|
||||
if span <= 0 or right <= left:
|
||||
raise ValueError("elongation clock day is not forward")
|
||||
origin_phase = self._phases[index]
|
||||
|
||||
def motion(jd: float) -> float:
|
||||
return left_angle + (self._phase(jd) - origin_phase + 180.0) % 360.0 - 180.0
|
||||
|
||||
slope = span / (right - left)
|
||||
guess = left + (target - left_angle) / span * (right - left)
|
||||
for _ in range(6):
|
||||
value = motion(guess)
|
||||
error = value - target
|
||||
if abs(error) <= 1e-6:
|
||||
return guess
|
||||
guess = min(right, max(left, guess - error / slope))
|
||||
low, high = left, right
|
||||
for _ in range(40):
|
||||
mid = (low + high) / 2.0
|
||||
error = motion(mid) - target
|
||||
if abs(error) <= 1e-6 or high - low < 1e-9:
|
||||
return mid
|
||||
if error < 0:
|
||||
low = mid
|
||||
else:
|
||||
high = mid
|
||||
return (low + high) / 2.0
|
||||
|
||||
|
||||
def hindu_solar_events(moment: datetime, latitude: float, longitude: float) -> list[dict]:
|
||||
"""Sunrise and sunset from the Swiss ephemeris file. No Moshier and no true-position flag."""
|
||||
if moment.utcoffset() is None:
|
||||
raise ValueError("solar events need a timezone")
|
||||
midnight = moment.astimezone(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
flags = swe.FLG_SWIEPH
|
||||
events = []
|
||||
for offset in range(-3, 4):
|
||||
jd = (midnight + timedelta(days=offset)).timestamp() / 86400.0 + 2440587.5
|
||||
for name, mode in (("sunrise", swe.CALC_RISE), ("sunset", swe.CALC_SET)):
|
||||
status, times = swe.rise_trans(
|
||||
jd,
|
||||
swe.SUN,
|
||||
mode | swe.BIT_HINDU_RISING,
|
||||
(float(longitude), float(latitude), 0.0),
|
||||
flags=flags,
|
||||
)
|
||||
if status == 0 and jd <= times[0] < jd + 1:
|
||||
stamp = datetime.fromtimestamp((times[0] - 2440587.5) * 86400, timezone.utc)
|
||||
events.append({"event": name, "datetime_utc": stamp.isoformat()})
|
||||
events.sort(key=lambda row: row["datetime_utc"])
|
||||
return events
|
||||
Reference in New Issue
Block a user