完整数据版补上研究口径的推运、次小运和细分盘(#92)。

研究章在渲染时计算,不写入资料包。四种星座大运次小运和 Kalachakra 第三层不改原来的主运、子运。D81、D108、D144 只留在研究章。

自测:test_research_timing、星座大运、域读取、卫生预算、真恒星、jhora 扫描、API 增长、六章标题、英文版。三张虚构盘中英出稿都低于 180 秒。PyJHora 已导入但未做逐层对照。

Co-Authored-By: Grok (agent loop)
This commit is contained in:
jesse-ux
2026-10-23 18:20:44 +08:00
co-authored by Grok (agent loop)
parent 85e8adddbc
commit d45e65a9ec
18 changed files with 2531 additions and 10 deletions
+16
View File
@@ -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")
-3
View File
@@ -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())
+1
View File
@@ -0,0 +1 @@
"""Research-only timing and high vargas. Not for chat cards or rectification."""
+474
View File
@@ -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"],
}
+315
View File
@@ -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()
+427
View File
@@ -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": "日出日落没有框住出生时刻,时段上升星座这里不列表。",
}
+46
View File
@@ -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))
+79
View File
@@ -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)
+500
View File
@@ -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)
+254
View File
@@ -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