标出研究章和 PyJHora 的已知差别(#92)。
10 张虚构盘逐体系对照。不一致的方法在标题下写明差在哪一类。本命 Tithi 顺行一致,逆行没有公开函数。Windows 上 1970 年以前的日出不再用 fromtimestamp。 自测:合同与研究章测试通过;200 张英文扫描约 1956 秒通过。未跑全量 pytest、快速门和前端。 Co-Authored-By: Grok (agent loop)
This commit is contained in:
co-authored by
Grok (agent loop)
parent
e586886196
commit
a757af6b4e
@@ -1,41 +1,768 @@
|
||||
"""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.
|
||||
PyJHora is imported only inside ``pyjhora_fingerprints``, and only when this
|
||||
file is run as a worker process. A missing public function is recorded as
|
||||
「无此方法」 and is not a mismatch.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import warnings
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
OUT = ROOT / "docs" / "tasks" / "research-timing-pyjhora-20261023.json"
|
||||
CHARTS = tuple(range(10))
|
||||
YEAR_BASIS = 365.25
|
||||
RETURN_MINUTE = 1.0
|
||||
CATEGORIES = (
|
||||
("起点", "start"),
|
||||
("方向", "direction"),
|
||||
("年数", "year count"),
|
||||
("子运划分", "sub-period division"),
|
||||
("回归时刻", "return moment"),
|
||||
)
|
||||
HEADINGS = {
|
||||
"tithi-natal-forward": ("Natal Tithi Ashtottari forward", "本命 Tithi Ashtottari 顺行", "exact"),
|
||||
"tithi-natal-reverse": ("Natal Tithi Ashtottari reverse", "本命 Tithi Ashtottari 逆行", "exact"),
|
||||
"yoga-natal": ("Natal Yoga Vimshottari", "本命 Yoga Vimshottari", "exact"),
|
||||
"buddhi-gati": ("Buddhi Gati", "Buddhi Gati", "exact"),
|
||||
"kaala": ("Kaala", "Kaala", "exact"),
|
||||
"sudasa-birth": ("Sudasa birth cycle", "Sudasa 出生周期", "exact"),
|
||||
"varnada": ("Varnada", "Varnada", "prefix"),
|
||||
"karana-julene": ("Julene lords", "Julene 主星", "prefix"),
|
||||
"karana-pvr": ("PVR quadrant lords", "PVR 象限主星", "prefix"),
|
||||
"karana-pvr-tithi-year": ("PVR 360 tithi year", "PVR 360 太阴月年", "prefix"),
|
||||
"sthira": ("Sthira", "Sthira", "exact"),
|
||||
"drig": ("Drig", "Drig", "exact"),
|
||||
"shoola": ("Shoola", "Shoola", "exact"),
|
||||
"navamsha": ("Navamsha", "Navamsha", "exact"),
|
||||
"kalachakra": ("Kalachakra", "Kalachakra", "exact"),
|
||||
}
|
||||
|
||||
|
||||
def _ensure_root() -> None:
|
||||
"""Match the test path: ``scripts/`` first so engine modules import, repo root second."""
|
||||
for entry in (str(ROOT / "scripts"), str(ROOT)):
|
||||
if entry in sys.path:
|
||||
sys.path.remove(entry)
|
||||
sys.path.insert(0, entry)
|
||||
|
||||
|
||||
def _span_years(start, end) -> float | None:
|
||||
if not start or not end:
|
||||
return None
|
||||
try:
|
||||
left = datetime.fromisoformat(str(start))
|
||||
right = datetime.fromisoformat(str(end))
|
||||
except ValueError:
|
||||
return None
|
||||
days = (right - left).total_seconds() / 86400.0
|
||||
if days <= 0:
|
||||
return None
|
||||
return round(days / YEAR_BASIS, 2)
|
||||
|
||||
|
||||
def _label(row: dict, kind: str) -> str:
|
||||
if kind == "sign":
|
||||
value = row.get("sign") or row.get("rashi") or row.get("lord")
|
||||
else:
|
||||
value = row.get("lord") or row.get("planet") or row.get("sign")
|
||||
return "" if value is None else str(value)
|
||||
|
||||
|
||||
def fingerprint_periods(periods: list, *, kind: str) -> dict:
|
||||
"""Lords or signs, nominal years, first period's children, and its first grandchildren."""
|
||||
maha: list[str] = []
|
||||
years: list[float | None] = []
|
||||
children: list[str] = []
|
||||
grand: list[str] = []
|
||||
for index, row in enumerate(periods or []):
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
maha.append(_label(row, kind))
|
||||
if "full_end_days" in row and "full_start_days" in row:
|
||||
span = (float(row["full_end_days"]) - float(row["full_start_days"])) / YEAR_BASIS
|
||||
years.append(round(span, 2))
|
||||
elif isinstance(row.get("years"), (int, float)) and not isinstance(row.get("years"), bool):
|
||||
years.append(round(float(row["years"]), 2))
|
||||
else:
|
||||
years.append(_span_years(row.get("start") or row.get("start_date"), row.get("end") or row.get("end_date")))
|
||||
if index != 0:
|
||||
continue
|
||||
kids = row.get("antardashas") or row.get("antardasha") or []
|
||||
for child in kids:
|
||||
if not isinstance(child, dict):
|
||||
continue
|
||||
children.append(_label(child, kind))
|
||||
if grand:
|
||||
continue
|
||||
thirds = child.get("pratyantardashas") or child.get("pratyantar") or child.get("pratyantardasha") or []
|
||||
for item in thirds:
|
||||
if isinstance(item, dict):
|
||||
grand.append(_label(item, kind))
|
||||
if not maha:
|
||||
return {"skipped": True}
|
||||
return {"maha": maha, "years": years, "children": children, "grand": grand}
|
||||
|
||||
|
||||
def _chart_input(index: int) -> tuple[dict, dict]:
|
||||
_ensure_root()
|
||||
from tests.report_english_fictional import fictional_case
|
||||
from scripts.research_timing.bundle import chart_from_parts
|
||||
|
||||
case = fictional_case(index)
|
||||
zone = timezone(timedelta(hours=float(case["tz"])))
|
||||
query = datetime(int(case["target_year"]), 1, 1, 12, 0, tzinfo=zone)
|
||||
chart = chart_from_parts(
|
||||
year=case["year"], month=case["month"], day=case["day"],
|
||||
hour=case["hour"], minute=case["minute"], second=case["second"],
|
||||
lat=case["lat"], lon=case["lon"], tz=case["tz"],
|
||||
ayanamsa=case["ayanamsa"], node_mode=case["node_mode"],
|
||||
target_year=case["target_year"], query=query,
|
||||
)
|
||||
return case, chart
|
||||
|
||||
|
||||
def _tithi_return_days(chart: dict) -> float | None:
|
||||
from scripts.research_timing.frame import SiderealFrame, birth_jd
|
||||
from scripts.research_timing.search import find_returns
|
||||
|
||||
frame = SiderealFrame(chart["ayanamsa"], chart["node_mode"])
|
||||
jd = birth_jd(chart["year"], chart["month"], chart["day"], chart["hour_decimal"], chart["tz"])
|
||||
found = find_returns(frame, "tithi_pravesha", jd, jd, jd + 400.0)
|
||||
for row in found.get("returns") or []:
|
||||
elapsed = float(row["jd_ut"]) - jd
|
||||
if elapsed > 1e-3:
|
||||
return elapsed
|
||||
return None
|
||||
|
||||
|
||||
def collect_native(indexes: tuple[int, ...] = CHARTS) -> dict:
|
||||
"""Our research periods for the fixed fictional charts. This process does not import jhora."""
|
||||
_ensure_root()
|
||||
from scripts.annual_native.dasha_children import attach_kalachakra_simplified_children
|
||||
from scripts.kalachakra_dasha import calculate_kalachakra_dasha
|
||||
from scripts.research_timing.bundle import _arc_remaining, _tithi_number
|
||||
from scripts.research_timing.cycles import (
|
||||
TITHI_FORWARD,
|
||||
TITHI_REVERSE,
|
||||
karana_year,
|
||||
tithi_ashtottari,
|
||||
yoga_vimshottari,
|
||||
)
|
||||
from scripts.research_timing.families import (
|
||||
buddhi_gati,
|
||||
hora_sign_from_events,
|
||||
kaala_from_events,
|
||||
karana_seed,
|
||||
sudasa_birth,
|
||||
varnada_jss,
|
||||
)
|
||||
from scripts.research_timing.frame import SiderealFrame, birth_jd
|
||||
from scripts.research_timing.rashi_pd import attach_rashi_pratyantar
|
||||
from scripts.research_timing.search import hindu_solar_events
|
||||
from scripts.rashi_dashas_native import calculate_rashi_dashas
|
||||
|
||||
payload = {}
|
||||
for index in indexes:
|
||||
try:
|
||||
_case, chart = _chart_input(index)
|
||||
except OSError:
|
||||
print(f"native chart {index} skipped", file=sys.stderr)
|
||||
continue
|
||||
year_days = float(chart["year_days"])
|
||||
frame = SiderealFrame(chart["ayanamsa"], chart["node_mode"])
|
||||
jd = birth_jd(chart["year"], chart["month"], chart["day"], chart["hour_decimal"], chart["tz"])
|
||||
sun, moon = frame.sun_moon(jd)
|
||||
epoch = chart["epoch"]
|
||||
systems: dict[str, dict] = {}
|
||||
|
||||
def keep(name: str, producer) -> None:
|
||||
try:
|
||||
systems[name] = producer()
|
||||
except (ValueError, RuntimeError, OverflowError, KeyError, TypeError, ZeroDivisionError, OSError) as exc:
|
||||
systems[name] = {"error": type(exc).__name__}
|
||||
|
||||
number = _tithi_number(sun, moon)
|
||||
fraction = _arc_remaining(sun, moon)
|
||||
keep("tithi-natal-forward", lambda: fingerprint_periods(tithi_ashtottari(
|
||||
epoch, number, fraction, 108 * year_days, TITHI_FORWARD, natal=True,
|
||||
)["periods"], kind="planet"))
|
||||
keep("tithi-natal-reverse", lambda: fingerprint_periods(tithi_ashtottari(
|
||||
epoch, number, fraction, 108 * year_days, TITHI_REVERSE, natal=True,
|
||||
)["periods"], kind="planet"))
|
||||
keep("yoga-natal", lambda: fingerprint_periods(yoga_vimshottari(
|
||||
epoch, (sun + moon) % 360.0, 120 * year_days, natal=True, depth=3,
|
||||
)["periods"], kind="planet"))
|
||||
keep("buddhi-gati", lambda: fingerprint_periods(
|
||||
buddhi_gati(int(chart["ascendant"] // 30), chart["longitudes"], epoch, year_days).get("periods") or [],
|
||||
kind="planet",
|
||||
))
|
||||
try:
|
||||
events = hindu_solar_events(epoch, chart["lat"], chart["lon"])
|
||||
except (ValueError, RuntimeError, OverflowError, KeyError, TypeError, OSError) as exc:
|
||||
events = None
|
||||
systems["kaala"] = {"error": type(exc).__name__}
|
||||
systems["varnada"] = {"error": type(exc).__name__}
|
||||
if events is not None:
|
||||
keep("kaala", lambda: fingerprint_periods(
|
||||
kaala_from_events(epoch, events, year_days).get("periods") or [], kind="planet",
|
||||
))
|
||||
try:
|
||||
hora = hora_sign_from_events(epoch, int(chart["ascendant"] // 30), events)
|
||||
if hora.get("status") != "calculated":
|
||||
systems["varnada"] = {"skipped": True}
|
||||
else:
|
||||
variants = varnada_jss(
|
||||
int(chart["ascendant"] // 30), int(hora["sign_index"]),
|
||||
chart["longitudes"], epoch, year_days,
|
||||
)
|
||||
parts = [fingerprint_periods(item.get("periods") or [], kind="sign") for item in variants]
|
||||
systems["varnada"] = {"variants": parts}
|
||||
except (ValueError, RuntimeError, OverflowError, KeyError, TypeError, OSError) as exc:
|
||||
systems["varnada"] = {"error": type(exc).__name__}
|
||||
for profile, span, key in (
|
||||
("julene", year_days, "karana-julene"),
|
||||
("pvr", year_days, "karana-pvr"),
|
||||
("pvr", 360.0, "karana-pvr-tithi-year"),
|
||||
):
|
||||
def karana_fp(profile=profile, span=span):
|
||||
seed = karana_seed(sun, moon, chart["ascendant"], "pvr" if profile == "pvr" else "julene")
|
||||
if seed.get("status") != "calculated":
|
||||
return {"skipped": True}
|
||||
built = karana_year(epoch, seed["lord"], float(seed["remaining_fraction"]), float(span))
|
||||
return fingerprint_periods(built.get("periods") or [], kind="planet")
|
||||
keep(key, karana_fp)
|
||||
birth = datetime(chart["year"], chart["month"], chart["day"]).date()
|
||||
try:
|
||||
copied = attach_rashi_pratyantar(calculate_rashi_dashas(birth, chart["ascendant"], chart["longitudes"]))
|
||||
for name in ("sthira", "drig", "shoola", "navamsha"):
|
||||
section = copied.get(name) if isinstance(copied.get(name), dict) else {}
|
||||
systems[name] = fingerprint_periods(section.get("periods") or [], kind="sign")
|
||||
except (ValueError, RuntimeError, OverflowError, KeyError, TypeError, OSError) as exc:
|
||||
for name in ("sthira", "drig", "shoola", "navamsha"):
|
||||
systems[name] = {"error": type(exc).__name__}
|
||||
try:
|
||||
moon_lon = float(chart["longitudes"]["Moon"])
|
||||
nak = int(moon_lon // (360.0 / 27.0)) % 27
|
||||
pada = int((moon_lon % (360.0 / 27.0)) // (360.0 / 108.0)) + 1
|
||||
# This calculator subtracts datetime.now(), which is naive. Keep the
|
||||
# clock time and drop the zone so the call can finish.
|
||||
birth_dt = epoch.replace(tzinfo=None) if isinstance(epoch, datetime) and epoch.tzinfo is not None else epoch
|
||||
calculated = calculate_kalachakra_dasha({
|
||||
"moon_nakshatra_index": nak,
|
||||
"moon_pada": pada,
|
||||
"birth_datetime": birth_dt,
|
||||
})
|
||||
enriched, _children, _grands = attach_kalachakra_simplified_children(calculated.get("major") or [])
|
||||
systems["kalachakra"] = fingerprint_periods(enriched, kind="sign")
|
||||
except (ValueError, RuntimeError, OverflowError, KeyError, TypeError, OSError) as exc:
|
||||
systems["kalachakra"] = {"error": type(exc).__name__}
|
||||
try:
|
||||
systems["sudasa-birth"] = fingerprint_periods(
|
||||
sudasa_birth(chart["ascendant"], chart["longitudes"], epoch, year_days).get("periods") or [],
|
||||
kind="sign",
|
||||
)
|
||||
except (ValueError, RuntimeError, OverflowError, KeyError, TypeError, OSError) as exc:
|
||||
systems["sudasa-birth"] = {"error": type(exc).__name__}
|
||||
try:
|
||||
return_days = _tithi_return_days(chart)
|
||||
except (ValueError, RuntimeError, OverflowError, KeyError, TypeError, OSError):
|
||||
return_days = None
|
||||
payload[str(index)] = {"systems": systems, "tithi_return_days": return_days}
|
||||
print(f"native chart {index}", file=sys.stderr)
|
||||
return payload
|
||||
|
||||
|
||||
def _as_name(value, kind: str) -> str:
|
||||
planets = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu")
|
||||
signs = (
|
||||
"Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
|
||||
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
|
||||
)
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
return str(value)
|
||||
index = int(value)
|
||||
table = planets if kind == "planet" else signs
|
||||
if 0 <= index < len(table):
|
||||
return table[index]
|
||||
return str(value)
|
||||
|
||||
|
||||
def _start_days(value):
|
||||
if isinstance(value, (list, tuple)) and len(value) >= 3:
|
||||
year, month, day = int(value[0]), int(value[1]), int(value[2])
|
||||
hours = float(value[3]) if len(value) > 3 else 0.0
|
||||
try:
|
||||
stamp = datetime(year, month, day) + timedelta(hours=hours)
|
||||
except ValueError:
|
||||
return None
|
||||
return stamp.toordinal() + hours / 24.0
|
||||
return None
|
||||
|
||||
|
||||
def _unpack_row(row):
|
||||
if not isinstance(row, (list, tuple)) or not row or not isinstance(row[0], (list, tuple)):
|
||||
return None
|
||||
lords = tuple(row[0])
|
||||
start = None
|
||||
duration = None
|
||||
for item in row[1:]:
|
||||
if isinstance(item, (list, tuple)) and start is None:
|
||||
start = _start_days(item)
|
||||
elif isinstance(item, (int, float)) and not isinstance(item, bool):
|
||||
duration = float(item)
|
||||
return lords, start, duration
|
||||
|
||||
|
||||
def fingerprint_rows(rows: list, *, kind: str) -> dict:
|
||||
parsed = []
|
||||
for row in rows or []:
|
||||
unpacked = _unpack_row(row)
|
||||
if unpacked is None:
|
||||
continue
|
||||
lords, start, duration = unpacked
|
||||
if not lords:
|
||||
continue
|
||||
parsed.append((tuple(_as_name(item, kind) for item in lords), start, duration))
|
||||
if not parsed:
|
||||
return {"skipped": True}
|
||||
groups = []
|
||||
for lords, start, duration in parsed:
|
||||
if not groups or groups[-1]["lord"] != lords[0]:
|
||||
groups.append({"lord": lords[0], "start": start, "durs": [], "children": [], "grands": {}})
|
||||
group = groups[-1]
|
||||
if duration is not None:
|
||||
group["durs"].append(duration)
|
||||
if len(lords) < 2:
|
||||
continue
|
||||
child = lords[1]
|
||||
if child not in group["grands"]:
|
||||
group["children"].append(child)
|
||||
group["grands"][child] = []
|
||||
if len(lords) >= 3:
|
||||
group["grands"][child].append(lords[2])
|
||||
maha = [group["lord"] for group in groups]
|
||||
years: list[float | None] = []
|
||||
for index, group in enumerate(groups):
|
||||
if group["durs"]:
|
||||
years.append(round(sum(group["durs"]), 2))
|
||||
continue
|
||||
nxt = groups[index + 1]["start"] if index + 1 < len(groups) else None
|
||||
if group["start"] is not None and nxt is not None:
|
||||
years.append(round((nxt - group["start"]) / YEAR_BASIS, 2))
|
||||
else:
|
||||
years.append(None)
|
||||
first = groups[0]
|
||||
children = first["children"]
|
||||
grand = first["grands"].get(children[0], []) if children else []
|
||||
return {"maha": maha, "years": years, "children": children, "grand": grand}
|
||||
|
||||
|
||||
def _rows_of(result):
|
||||
if isinstance(result, list):
|
||||
return result
|
||||
if isinstance(result, tuple):
|
||||
for item in result:
|
||||
if isinstance(item, list):
|
||||
return item
|
||||
return []
|
||||
|
||||
|
||||
def pyjhora_fingerprints(indexes: tuple[int, ...] = CHARTS) -> dict:
|
||||
"""PyJHora periods. Importing jhora here is intentional and stays in this process."""
|
||||
warnings.filterwarnings("ignore")
|
||||
_ensure_root()
|
||||
import jhora
|
||||
from jhora import const, utils
|
||||
from jhora.panchanga import drik
|
||||
from jhora.horoscope.dhasa.graha import buddhi_gathi, kaala, karana_chathuraaseethi_sama, tithi_ashtottari, yoga_vimsottari
|
||||
from jhora.horoscope.dhasa.raasi import drig, kalachakra, navamsa, shoola, sthira, sudasa, varnada
|
||||
|
||||
version = getattr(jhora, "__version__", None)
|
||||
if not version:
|
||||
try:
|
||||
from importlib.metadata import version as pkg_version
|
||||
version = pkg_version("PyJHora")
|
||||
except (ImportError, OSError, ValueError):
|
||||
version = "installed"
|
||||
charts = {}
|
||||
for index in indexes:
|
||||
try:
|
||||
case, _chart = _chart_input(index)
|
||||
mode = {"lahiri": "LAHIRI", "raman": "RAMAN", "kp": "KP"}.get(str(case["ayanamsa"]).lower(), "LAHIRI")
|
||||
drik.set_planet_list(set_rahu_ketu_as_true_nodes=str(case["node_mode"]).lower() == "true", include_western_planets=False)
|
||||
drik.set_ayanamsa_mode(mode)
|
||||
place = drik.Place("fictional", case["lat"], case["lon"], case["tz"], elevation=0.0)
|
||||
dob = drik.Date(int(case["year"]), int(case["month"]), int(case["day"]))
|
||||
tob = (int(case["hour"]), int(case["minute"]), int(case["second"]))
|
||||
jd = utils.julian_day_number(dob, tob)
|
||||
except OSError:
|
||||
print(f"pyjhora chart {index} skipped", file=sys.stderr)
|
||||
continue
|
||||
systems: dict[str, dict] = {}
|
||||
|
||||
def run(name: str, kind: str, producer) -> None:
|
||||
try:
|
||||
produced = producer()
|
||||
except AttributeError:
|
||||
# The public names are imported above. An AttributeError from the
|
||||
# call itself is a broken invocation, not 「无此方法」.
|
||||
systems[name] = {"error": "AttributeError"}
|
||||
return
|
||||
except (TypeError, ValueError, RuntimeError, OverflowError, KeyError, ZeroDivisionError, OSError) as exc:
|
||||
systems[name] = {"error": type(exc).__name__}
|
||||
return
|
||||
try:
|
||||
systems[name] = fingerprint_rows(_rows_of(produced), kind=kind)
|
||||
except (TypeError, ValueError, RuntimeError, OverflowError, KeyError, ZeroDivisionError, OSError, AttributeError) as exc:
|
||||
systems[name] = {"error": type(exc).__name__}
|
||||
|
||||
run("tithi-natal-forward", "planet", lambda: tithi_ashtottari.get_dhasa_bhukthi(jd, place, dhasa_level_index=2))
|
||||
systems["tithi-natal-reverse"] = {"missing": True}
|
||||
run("yoga-natal", "planet", lambda: yoga_vimsottari.get_dhasa_bhukthi(jd, place, dhasa_level_index=3))
|
||||
run("buddhi-gati", "planet", lambda: buddhi_gathi.get_dhasa_bhukthi(dob, tob, place, dhasa_level_index=2))
|
||||
run("kaala", "planet", lambda: kaala.get_dhasa_antardhasa(dob, tob, place, dhasa_level_index=2))
|
||||
run("sudasa-birth", "sign", lambda: sudasa.get_dhasa_bhukthi(dob, tob, place, dhasa_level_index=2))
|
||||
run("varnada", "sign", lambda: varnada.get_dhasa_antardhasa(dob, tob, place, dhasa_level_index=2))
|
||||
run("karana-julene", "planet", lambda: karana_chathuraaseethi_sama.get_dhasa_bhukthi(dob, tob, place, dhasa_level_index=2))
|
||||
systems["karana-pvr"] = systems["karana-julene"]
|
||||
systems["karana-pvr-tithi-year"] = systems["karana-julene"]
|
||||
run("sthira", "sign", lambda: sthira.get_dhasa_antardhasa(dob, tob, place, dhasa_level_index=3))
|
||||
run("drig", "sign", lambda: drig.get_dhasa_antardhasa(jd, place, dhasa_level_index=3))
|
||||
run("shoola", "sign", lambda: shoola.get_dhasa_bhukthi(dob, tob, place, dhasa_level_index=3))
|
||||
run("navamsha", "sign", lambda: navamsa.get_dhasa_antardhasa(dob, tob, place, dhasa_level_index=3))
|
||||
def kala_rows():
|
||||
jd_utc = jd - float(case["tz"]) / 24.0
|
||||
moon = drik.sidereal_longitude(jd_utc, const._MOON)
|
||||
return kalachakra.kalachakra_dhasa(moon, jd, dhasa_level_index=3, place=place)
|
||||
run("kalachakra", "sign", kala_rows)
|
||||
try:
|
||||
systems["tithi_return_days"] = _py_tithi_return_days(jd, float(case["tz"]))
|
||||
except (TypeError, ValueError, RuntimeError, OverflowError, KeyError, ZeroDivisionError, OSError):
|
||||
systems["tithi_return_days"] = None
|
||||
charts[str(index)] = systems
|
||||
print(f"pyjhora chart {index}", file=sys.stderr)
|
||||
return {"version": str(version), "charts": charts}
|
||||
|
||||
|
||||
def _py_tithi_return_days(jd_local: float, tz: float) -> float | None:
|
||||
from jhora import const
|
||||
from jhora.panchanga import drik
|
||||
|
||||
jd_utc = jd_local - tz / 24.0
|
||||
phase0 = drik.lunar_phase(jd_utc)
|
||||
sign0 = int(drik.sidereal_longitude(jd_utc, const._SUN) // 30)
|
||||
|
||||
def residual(offset: float) -> float:
|
||||
phase = drik.lunar_phase(jd_utc + offset)
|
||||
return (phase - phase0 + 180.0) % 360.0 - 180.0
|
||||
|
||||
left = 0.05
|
||||
left_value = residual(left)
|
||||
while left < 400.0:
|
||||
right = min(left + 0.25, 400.0)
|
||||
right_value = residual(right)
|
||||
if left_value * right_value <= 0 and abs(right_value - left_value) < 180.0:
|
||||
lo, hi, lo_value = left, right, left_value
|
||||
for _ in range(40):
|
||||
mid = (lo + hi) / 2.0
|
||||
mid_value = residual(mid)
|
||||
if lo_value * mid_value <= 0:
|
||||
hi = mid
|
||||
else:
|
||||
lo, lo_value = mid, mid_value
|
||||
root = (lo + hi) / 2.0
|
||||
if abs(residual(root)) < 1e-3 and int(drik.sidereal_longitude(jd_utc + root, const._SUN) // 30) == sign0:
|
||||
return root
|
||||
left, left_value = right, right_value
|
||||
return None
|
||||
|
||||
|
||||
def _base_cycle(seq: list[str]) -> list[str]:
|
||||
"""Shortest prefix that tiles ``seq``, so one extra lord of the same cycle still matches."""
|
||||
for size in range(1, len(seq) + 1):
|
||||
cycle = seq[:size]
|
||||
if all(seq[index] == cycle[index % size] for index in range(len(seq))):
|
||||
return cycle
|
||||
return seq
|
||||
|
||||
|
||||
def _same_lords(left: list[str], right: list[str]) -> bool:
|
||||
"""Same order, including when one side continues a partial repeat of the other's cycle."""
|
||||
if left == right:
|
||||
return True
|
||||
if not left or not right:
|
||||
return False
|
||||
short, long = (left, right) if len(left) <= len(right) else (right, left)
|
||||
cycle = _base_cycle(short)
|
||||
if not cycle:
|
||||
return False
|
||||
return all(name == cycle[index % len(cycle)] for index, name in enumerate(long))
|
||||
|
||||
|
||||
def _is_reverse(left: list[str], right: list[str]) -> bool:
|
||||
if not left or not right:
|
||||
return False
|
||||
return _same_lords(list(reversed(left)), right)
|
||||
|
||||
|
||||
def _order_example(left: list[str], right: list[str]) -> str:
|
||||
"""Name the first period that differs. Never say X vs X when the opening lords match."""
|
||||
count = min(len(left), len(right))
|
||||
for index in range(count):
|
||||
if left[index] != right[index]:
|
||||
return f"period {index + 1} native {left[index]} vs PyJHora {right[index]}"
|
||||
return f"period count native {len(left)} vs PyJHora {len(right)}"
|
||||
|
||||
|
||||
def _years_match(left: list, right: list, lords_left: list[str], lords_right: list[str]) -> bool:
|
||||
count = min(len(left), len(right), len(lords_left), len(lords_right))
|
||||
pairs = []
|
||||
for index in range(count):
|
||||
a, b = left[index], right[index]
|
||||
if isinstance(a, (int, float)) and isinstance(b, (int, float)):
|
||||
pairs.append((float(a), float(b)))
|
||||
if not pairs:
|
||||
return True
|
||||
return all(abs(a - b) <= 0.35 for a, b in pairs)
|
||||
|
||||
|
||||
def _slice_match(native: list[str], other: list[str]) -> bool:
|
||||
if native == other:
|
||||
return True
|
||||
if not native or not other:
|
||||
return False
|
||||
if len(native) > len(other):
|
||||
native, other = other, native
|
||||
return any(other[index:index + len(native)] == native for index in range(len(other) - len(native) + 1))
|
||||
|
||||
|
||||
def _usable(fp: dict | None) -> bool:
|
||||
return isinstance(fp, dict) and not fp.get("skipped") and not fp.get("error") and not fp.get("missing") and bool(fp.get("maha"))
|
||||
|
||||
|
||||
def classify_pair(native: dict, other: dict, *, chart: int, return_days: tuple[float | None, float | None] | None = None) -> dict:
|
||||
if isinstance(other, dict) and other.get("missing"):
|
||||
return {"status": "无此方法", "chart": chart}
|
||||
if (isinstance(native, dict) and native.get("error")) or (isinstance(other, dict) and other.get("error")):
|
||||
return {"status": "error", "chart": chart}
|
||||
if not isinstance(native, dict) or not isinstance(other, dict):
|
||||
return {"status": "skipped", "chart": chart}
|
||||
native_rows = native.get("variants") if isinstance(native.get("variants"), list) else [native]
|
||||
other_rows = other.get("variants") if isinstance(other.get("variants"), list) else [other]
|
||||
usable_native = [row for row in native_rows if _usable(row)]
|
||||
usable_other = [row for row in other_rows if _usable(row)]
|
||||
if not usable_native or not usable_other:
|
||||
if not usable_native and not usable_other:
|
||||
return {"status": "skipped", "chart": chart}
|
||||
return _mismatch("起点", chart, "one side listed no periods")
|
||||
verdicts = []
|
||||
for native_fp in usable_native:
|
||||
for other_fp in usable_other:
|
||||
verdicts.append(_classify_rows(native_fp, other_fp, chart, return_days if len(verdicts) == 0 else None))
|
||||
mismatches = [item for item in verdicts if item["status"] == "不一致"]
|
||||
if not mismatches and any(item["status"] == "一致" for item in verdicts):
|
||||
agreed = next(item for item in verdicts if item["status"] == "一致")
|
||||
return agreed
|
||||
if mismatches:
|
||||
return mismatches[0]
|
||||
return {"status": "skipped", "chart": chart}
|
||||
|
||||
|
||||
def _classify_rows(native: dict, other: dict, chart: int, return_days) -> dict:
|
||||
left = [str(item) for item in native.get("maha") or []]
|
||||
right = [str(item) for item in other.get("maha") or []]
|
||||
if not _same_lords(left, right):
|
||||
if _is_reverse(left, right):
|
||||
return _mismatch("方向", chart, f"period order runs the other way, {_order_example(left, right)}")
|
||||
return _mismatch("起点", chart, _order_example(left, right))
|
||||
if not _years_match(native.get("years") or [], other.get("years") or [], left, right):
|
||||
return _mismatch("年数", chart, f"year count differs at the first compared period of {left[0]}")
|
||||
if not _slice_match(list(native.get("children") or []), list(other.get("children") or [])):
|
||||
return _mismatch("子运划分", chart, f"child periods under {left[0]} do not line up")
|
||||
if not _slice_match(list(native.get("grand") or []), list(other.get("grand") or [])):
|
||||
return _mismatch("子运划分", chart, f"third level under {left[0]} does not line up")
|
||||
note = _return_note(return_days)
|
||||
if note and note["status"] == "不一致":
|
||||
return note
|
||||
agreed = {"status": "一致", "chart": chart}
|
||||
if note and note.get("within_tolerance") is not None:
|
||||
agreed["within_tolerance"] = note["within_tolerance"]
|
||||
return agreed
|
||||
|
||||
|
||||
def _mismatch(category: str, chart: int, example: str) -> dict:
|
||||
english = dict(CATEGORIES)[category]
|
||||
return {
|
||||
"status": "不一致",
|
||||
"category": category,
|
||||
"category_en": english,
|
||||
"example": f"chart {chart} {example}",
|
||||
"chart": chart,
|
||||
}
|
||||
|
||||
|
||||
def _return_note(pair) -> dict | None:
|
||||
if pair is None:
|
||||
return None
|
||||
native, other = pair
|
||||
if native is None and other is None:
|
||||
return None
|
||||
if native is None or other is None:
|
||||
return {
|
||||
"status": "不一致",
|
||||
"category": "回归时刻",
|
||||
"category_en": "return moment",
|
||||
"example": "tithi return is present on only one side",
|
||||
}
|
||||
minutes = abs(float(native) - float(other)) * 1440.0
|
||||
if minutes <= RETURN_MINUTE:
|
||||
return {"status": "一致", "within_tolerance": round(minutes, 3)}
|
||||
return {
|
||||
"status": "不一致",
|
||||
"category": "回归时刻",
|
||||
"category_en": "return moment",
|
||||
"example": f"tithi return differs by {minutes:.1f} minutes",
|
||||
}
|
||||
|
||||
|
||||
def _merge_status(rows: list[dict]) -> dict:
|
||||
errors = [row for row in rows if row.get("status") == "error"]
|
||||
comparable = [row for row in rows if row.get("status") not in {"skipped", "error"}]
|
||||
if not comparable:
|
||||
return {"status": "error"} if errors else {"status": "无此方法"}
|
||||
if all(row.get("status") == "无此方法" for row in comparable):
|
||||
return {"status": "无此方法"}
|
||||
mismatches = [row for row in comparable if row.get("status") == "不一致"]
|
||||
if mismatches:
|
||||
chosen = sorted(mismatches, key=lambda row: (CATEGORIES.index((row["category"], row["category_en"])), row.get("chart", 0)))[0]
|
||||
return {
|
||||
"status": "不一致",
|
||||
"category": chosen["category"],
|
||||
"category_en": chosen["category_en"],
|
||||
"example": chosen.get("example") or "",
|
||||
}
|
||||
if any(row.get("status") == "一致" for row in comparable):
|
||||
return {"status": "一致"}
|
||||
return {"status": "无此方法"}
|
||||
|
||||
|
||||
def _system_record(key: str, merged: dict) -> dict:
|
||||
en, zh, match = HEADINGS[key]
|
||||
record = {
|
||||
"status": merged["status"],
|
||||
"heading_en": en,
|
||||
"heading_zh": zh,
|
||||
"match": match,
|
||||
}
|
||||
if merged["status"] == "不一致":
|
||||
record["category"] = merged["category"]
|
||||
record["category_en"] = merged["category_en"]
|
||||
record["example"] = merged.get("example") or ""
|
||||
return record
|
||||
|
||||
|
||||
def build_report(native: dict, pyjhora: dict) -> dict:
|
||||
indexes = sorted({int(key) for key in native} & {int(key) for key in pyjhora.get("charts") or {}})
|
||||
within = []
|
||||
systems = {}
|
||||
for key in HEADINGS:
|
||||
rows = []
|
||||
for index in indexes:
|
||||
ours = (native[str(index)].get("systems") or {}).get(key) or {"skipped": True}
|
||||
theirs = (pyjhora["charts"][str(index)] or {}).get(key) or {"skipped": True}
|
||||
pair = None
|
||||
if key == "tithi-natal-forward":
|
||||
pair = (
|
||||
native[str(index)].get("tithi_return_days"),
|
||||
(pyjhora["charts"][str(index)] or {}).get("tithi_return_days"),
|
||||
)
|
||||
verdict = classify_pair(ours, theirs, chart=index, return_days=pair)
|
||||
if verdict.get("status") == "不一致" and verdict.get("category") == "回归时刻":
|
||||
verdict["example"] = f"chart {index} {verdict.get('example') or ''}".replace(f"chart {index} chart {index} ", f"chart {index} ")
|
||||
if verdict.get("within_tolerance") is not None:
|
||||
within.append({"system": key, "chart": index, "minutes": verdict["within_tolerance"]})
|
||||
rows.append(verdict)
|
||||
systems[key] = _system_record(key, _merge_status(rows))
|
||||
return {
|
||||
"status": "compared",
|
||||
"charts": len(indexes),
|
||||
"chart_indexes": indexes,
|
||||
"pyjhora_version": pyjhora.get("version") or "",
|
||||
"return_rule": (
|
||||
"A return moment within 1 minute is listed in within_tolerance and is not a mismatch. "
|
||||
"The two sides use different ayanamsa implementations and ephemeris files."
|
||||
),
|
||||
"omitted_from_labels": [
|
||||
"Sudasa confirmed start has no written full major start on our side, so it is not compared.",
|
||||
"Niryaana Shoola child periods stay omitted by the 2026-10-23 decision.",
|
||||
"Annual Tithi, Yoga, and Nakshatra headings are outside this pass.",
|
||||
],
|
||||
"within_tolerance": within,
|
||||
"systems": systems,
|
||||
}
|
||||
|
||||
|
||||
def _write(payload: dict) -> None:
|
||||
OUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
OUT.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
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 = {
|
||||
_ensure_root()
|
||||
if "--pyjhora-worker" in sys.argv:
|
||||
slot = sys.argv.index("--pyjhora-worker")
|
||||
target = Path(sys.argv[slot + 1])
|
||||
indexes = tuple(int(item) for item in sys.argv[slot + 2:]) or CHARTS
|
||||
target.write_text(json.dumps(pyjhora_fingerprints(indexes), ensure_ascii=False), encoding="utf-8")
|
||||
print(target)
|
||||
return 0
|
||||
if importlib.util.find_spec("jhora") is None:
|
||||
_write({
|
||||
"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)
|
||||
"error_type": "ImportError",
|
||||
"systems": {},
|
||||
})
|
||||
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")
|
||||
native = collect_native(CHARTS)
|
||||
worker_out = Path(tempfile.gettempdir()) / "jyotisha-92-pyjhora.json"
|
||||
command = [
|
||||
sys.executable, "-m", "scripts.research.research_timing_pyjhora_compare",
|
||||
"--pyjhora-worker", str(worker_out), *[str(index) for index in CHARTS],
|
||||
]
|
||||
completed = subprocess.run(command, cwd=str(ROOT), check=False)
|
||||
if completed.returncode != 0 or not worker_out.exists():
|
||||
print("pyjhora worker failed", file=sys.stderr)
|
||||
return 1
|
||||
pyjhora = json.loads(worker_out.read_text(encoding="utf-8"))
|
||||
worker_out.unlink(missing_ok=True)
|
||||
report = build_report(native, pyjhora)
|
||||
if report["charts"] < 5:
|
||||
print(f"only {report['charts']} charts", file=sys.stderr)
|
||||
return 1
|
||||
failed = [key for key, item in report["systems"].items() if item.get("status") == "error"]
|
||||
if failed:
|
||||
print("call failed on every chart: " + ",".join(failed), file=sys.stderr)
|
||||
return 1
|
||||
_write(report)
|
||||
counts = {}
|
||||
for item in report["systems"].values():
|
||||
counts[item["status"]] = counts.get(item["status"], 0) + 1
|
||||
print(json.dumps({"charts": report["charts"], "counts": counts}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -6,9 +6,11 @@ on the packet, and they are not sent to chat cards or rectification.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -291,7 +293,56 @@ def _chapter(title: str, language: str, body: str) -> str:
|
||||
|
||||
|
||||
def _family_block(title: str, language: str, body: str) -> str:
|
||||
return f"## {title}\n\n{BANNER[language]}\n\n{body.strip()}"
|
||||
note = _pyjhora_note(title, language)
|
||||
text = f"## {title}\n\n{BANNER[language]}"
|
||||
if note:
|
||||
text += "\n\n" + note
|
||||
if body.strip():
|
||||
text += "\n\n" + body.strip()
|
||||
return text
|
||||
|
||||
|
||||
_PYJHORA_NOTES: dict | None = None
|
||||
|
||||
|
||||
def _pyjhora_notes() -> dict:
|
||||
"""Committed offline comparison. Missing or unread JSON adds no label."""
|
||||
global _PYJHORA_NOTES
|
||||
if _PYJHORA_NOTES is None:
|
||||
path = Path(__file__).resolve().parents[2] / "docs" / "tasks" / "research-timing-pyjhora-20261023.json"
|
||||
notes: dict[tuple[str, str], str] = {}
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
payload = {}
|
||||
systems = payload.get("systems") if isinstance(payload, dict) else None
|
||||
for item in (systems or {}).values():
|
||||
if not isinstance(item, dict) or item.get("status") != "不一致":
|
||||
continue
|
||||
zh = str(item.get("heading_zh") or "")
|
||||
en = str(item.get("heading_en") or "")
|
||||
category = str(item.get("category") or "")
|
||||
category_en = str(item.get("category_en") or "")
|
||||
match = item.get("match") or "exact"
|
||||
if zh and category:
|
||||
notes[("zh", zh, match)] = f"与 PyJHora 不一致:差在 {category}"
|
||||
if en and category_en:
|
||||
notes[("en", en, match)] = f"Differs from PyJHora: {category_en}"
|
||||
_PYJHORA_NOTES = notes
|
||||
return _PYJHORA_NOTES
|
||||
|
||||
|
||||
def _pyjhora_note(title: str, language: str) -> str:
|
||||
best = ""
|
||||
found = ""
|
||||
for (lang, heading, match), sentence in _pyjhora_notes().items():
|
||||
if lang != language or not heading:
|
||||
continue
|
||||
if title == heading or (match == "prefix" and title.startswith(heading + " ")):
|
||||
if len(heading) >= len(best):
|
||||
best = heading
|
||||
found = sentence
|
||||
return found
|
||||
|
||||
|
||||
def _heading(key: str, language: str) -> str:
|
||||
|
||||
@@ -24,6 +24,21 @@ except ImportError: # pragma: no cover - package import
|
||||
_STEP = 0.125
|
||||
_BISECTIONS = 45
|
||||
_TOLERANCE = 1e-6
|
||||
_UNIX_EPOCH_JD = 2440587.5
|
||||
|
||||
|
||||
def _utc_from_jd(jd: float) -> datetime:
|
||||
"""UTC from a Julian day. Windows ``fromtimestamp`` rejects instants before 1970."""
|
||||
unix = (float(jd) - _UNIX_EPOCH_JD) * 86400.0
|
||||
seconds = int(unix)
|
||||
micros = int(round((unix - seconds) * 1_000_000))
|
||||
if micros >= 1_000_000:
|
||||
seconds += 1
|
||||
micros -= 1_000_000
|
||||
elif micros < 0:
|
||||
seconds -= 1
|
||||
micros += 1_000_000
|
||||
return datetime(1970, 1, 1, tzinfo=timezone.utc) + timedelta(seconds=seconds, microseconds=micros)
|
||||
|
||||
|
||||
def find_returns(
|
||||
@@ -248,7 +263,7 @@ def hindu_solar_events(moment: datetime, latitude: float, longitude: float) -> l
|
||||
flags=flags,
|
||||
)
|
||||
if status == 0 and jd <= times[0] < jd + 1:
|
||||
stamp = datetime.fromtimestamp((times[0] - 2440587.5) * 86400, timezone.utc)
|
||||
stamp = _utc_from_jd(times[0])
|
||||
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