From 3f86f6d77c0e993cea6016ba84eb31d072207afa Mon Sep 17 00:00:00 2001 From: 732642856 <732642856@qq.com> Date: Thu, 16 Jul 2026 21:54:34 +0800 Subject: [PATCH] Harden prashna production entrypoints --- jyotish-app/index.html | 10 ++- jyotish-app/main.js | 23 ++++- scripts/gulika.py | 67 ++++++++++++++ scripts/jyotish_api_server.py | 22 +++++ scripts/jyotish_engine.py | 91 ++++++++++--------- scripts/prashna_context.py | 105 ++++++++++++++++++++++ scripts/prashna_sphuta.py | 44 +++++++++ scripts/saham_daynight.py | 41 +++++++++ scripts/tajika_kernel.py | 79 ++++++++++++++++ tests/test_full_reading_saham_contract.py | 15 ++++ tests/test_gulika.py | 17 ++++ tests/test_prashna_context.py | 32 +++++++ tests/test_prashna_entry_contract.py | 62 +++++++++++++ tests/test_prashna_sphuta.py | 23 +++++ tests/test_saham_daynight.py | 13 +++ tests/test_tajika_kernel.py | 33 +++++++ 16 files changed, 626 insertions(+), 51 deletions(-) create mode 100644 scripts/gulika.py create mode 100644 scripts/prashna_context.py create mode 100644 scripts/prashna_sphuta.py create mode 100644 scripts/saham_daynight.py create mode 100644 scripts/tajika_kernel.py create mode 100644 tests/test_full_reading_saham_contract.py create mode 100644 tests/test_gulika.py create mode 100644 tests/test_prashna_context.py create mode 100644 tests/test_prashna_entry_contract.py create mode 100644 tests/test_prashna_sphuta.py create mode 100644 tests/test_saham_daynight.py create mode 100644 tests/test_tajika_kernel.py diff --git a/jyotish-app/index.html b/jyotish-app/index.html index e1e869cc..0d1cfa42 100644 --- a/jyotish-app/index.html +++ b/jyotish-app/index.html @@ -613,11 +613,19 @@
+ + + +
+ + + +
-

Prashna 使用提问当下的时刻与当前星盘数据判断具体问题,适合一次只问一个清晰问题。

+

Prashna 仅使用提问当下时刻与地点由后端排盘;不使用本命盘替代问事盘。

diff --git a/jyotish-app/main.js b/jyotish-app/main.js index 70bfbf1a..253420e5 100644 --- a/jyotish-app/main.js +++ b/jyotish-app/main.js @@ -7177,6 +7177,10 @@ function renderPrashnaTab(chartData) { const question = $('prashna-question'); const result = $('prashna-result'); const runBtn = $('btn-run-prashna'); + const timestamp = $('prashna-timestamp'); + const lat = $('prashna-lat'); + const lon = $('prashna-lon'); + const timezone = $('prashna-timezone'); if (!category || !question || !result || !runBtn) return; const workflow = chartData?._consultationWorkflow; renderPrashnaCaseWorkspace(); @@ -7191,6 +7195,14 @@ function renderPrashnaTab(chartData) { runBtn.addEventListener('click', async () => { const questionText = question.value.trim(); const questionType = category.value || 'general'; + if (!questionText) { + result.innerHTML = '

请填写一个明确问题。

'; + return; + } + if (!timestamp?.value || !lat?.value || !lon?.value || !timezone?.value) { + result.innerHTML = '

请填写提问时刻、纬度、经度与 UTC 时区。

'; + return; + } if (questionText.length > 120) { result.innerHTML = '

问题请控制在 120 字以内。

'; return; @@ -7198,11 +7210,14 @@ function renderPrashnaTab(chartData) { result.innerHTML = '

正在铸造 Prashna 问事盘...

'; try { const data = await window.JyotishAPI?.computePrashna?.({ - question: questionType, question_text: questionText, - planets: chartData?.planets || {}, - asc_degree: chartData?.ascendant?.lon ?? chartData?.ascendant?.degree ?? 15.5, - horary_number: chartData?.kp_horary?.horary_number || '', + question_timestamp: timestamp.value, + lat: Number(lat.value), + lon: Number(lon.value), + timezone: Number(timezone.value), + ayanamsa: 'lahiri', + node_mode: 'mean', + location_convention: 'wgs84', }); if (!data) throw new Error('本地 API 未返回结果'); recordPrashnaWorkflow(data, questionText, questionType); diff --git a/scripts/gulika.py b/scripts/gulika.py new file mode 100644 index 00000000..f92f439b --- /dev/null +++ b/scripts/gulika.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Swiss-Ephemeris Gulika calculator using the Prasna Marga Ghatika table.""" +from __future__ import annotations + +from datetime import datetime +from typing import Any + +import swisseph as swe + +try: + from saham_daynight import determine_daytime +except ImportError: + from scripts.saham_daynight import determine_daytime + + +# Monday=0, matching datetime.weekday(). Values are the end of Saturn's share +# measured in Ghatika from the relevant sunrise/sunset (30 Ghatika per period). +GHATIKA_END = { + 0: {"day": 22, "night": 6}, + 1: {"day": 18, "night": 2}, + 2: {"day": 14, "night": 26}, + 3: {"day": 10, "night": 22}, + 4: {"day": 6, "night": 18}, + 5: {"day": 2, "night": 14}, + 6: {"day": 26, "night": 10}, +} + + +def _sidereal_ascendant(jd_ut: float, lat: float, lon: float) -> float: + swe.set_sid_mode(swe.SIDM_LAHIRI) + cusps, ascmc = swe.houses_ex(jd_ut, lat, lon, b"P", swe.FLG_SIDEREAL) + return float(ascmc[0]) % 360 + + +def calculate_gulika( + moment: datetime, + *, + lat: float, + lon: float, + tz: float, +) -> dict[str, Any]: + """Return Gulika from local moment/location using Swiss sunrise and sunset.""" + daynight = determine_daytime(moment, lat=lat, lon=lon, tz=tz) + is_day = bool(daynight["is_daytime"]) + period = "day" if is_day else "night" + ghatika_end = GHATIKA_END[moment.weekday()][period] + start_jd = daynight["sunrise_jd_ut"] if is_day else daynight["sunset_jd_ut"] + end_jd = daynight["sunset_jd_ut"] if is_day else daynight["sunrise_jd_ut"] + 1.0 + if end_jd <= start_jd: + end_jd += 1.0 + segment_jd = start_jd + (end_jd - start_jd) * (ghatika_end / 30.0) + longitude = _sidereal_ascendant(segment_jd, float(lat), float(lon)) + return { + "scope": "gulika_prasna_marga", + "status": "partial", + "longitude": round(longitude, 6), + "sign_idx": int(longitude / 30) % 12, + "degree_in_sign": round(longitude % 30, 6), + "period": period, + "weekday": moment.weekday(), + "ghatika_end": ghatika_end, + "segment_jd_ut": segment_jd, + "daynight_evidence": daynight, + "ayanamsa": "lahiri", + "rule_source": "references/prashna-complete-guide.md#3.5", + "boundary": "Formula is implemented from the local classical guide; external JHora/PyJHora numeric parity remains required before enabling Sphuta or verdict layers.", + } diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index 4e3e88e7..ea4e56d2 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -5036,12 +5036,34 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): return calc_kp_analysis(planets, SIGNS[asc_idx]) def _compute_prashna(self, body): + try: + from prashna_context import PrashnaContextError, build_prashna_context + except ModuleNotFoundError: # pragma: no cover - package import path + from scripts.prashna_context import PrashnaContextError, build_prashna_context question_type = body.get('question', 'general') if not isinstance(question_type, str): raise BadRequest('question must be a string') question_text = body.get('question_text', '') if not isinstance(question_text, str): raise BadRequest('question_text must be a string') + if "question_text" in body and not isinstance(body["question_text"], str): + raise BadRequest('question_text must be a string') + if "planets" in body or "asc_degree" in body: + raise BadRequest("Prashna planets and ascendant are backend-computed; client values are forbidden") + try: + context = build_prashna_context(body) + except PrashnaContextError as exc: + raise BadRequest(str(exc)) from exc + return { + "success": True, + "status": "computed", + "prashna_context": context, + "verdict": { + "status": "blocked", + "reason": "Prashna adjudication is disabled until Tajika/Saham/Sphuta kernels pass classic golden cases.", + }, + } + # Legacy client-supplied-chart pipeline below is unreachable pending deletion. from prashna import ( QUESTION_CATEGORIES, analyze_lost_item, diff --git a/scripts/jyotish_engine.py b/scripts/jyotish_engine.py index 241d037e..6b94f848 100644 --- a/scripts/jyotish_engine.py +++ b/scripts/jyotish_engine.py @@ -5085,14 +5085,26 @@ def cmd_full_reading(args): try: from tajika import calc_tajika_yogas, calc_all_sahams - # Tajika Yogas(用本命盘行星经度) - tc_yogas = calc_tajika_yogas(planet_lons) + # Seven-planet Tajika candidates require actual instantaneous speed. + tajika_planets = { + name: {"longitude": item.get("degree_raw", item.get("lon")), "speed": item.get("speed")} + for name, item in planets.items() + if isinstance(item, dict) and name in {"Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn"} + } + tc_yogas = calc_tajika_yogas(tajika_planets) report['modules']['tajika_yogas'] = tc_yogas # Sahams(特殊点)—— 需要出生时间 - birth_dt = getattr(args, 'birth_datetime', None) + birth_dt = _birth_datetime_from_args(args) if birth_dt and planet_lons: - sahams_result = calc_all_sahams(planet_lons, asc_deg, birth_dt) + sahams_result = calc_all_sahams( + planet_lons, + asc_deg, + birth_dt, + lat=getattr(args, 'lat', None), + lon=getattr(args, 'lon', None), + tz=getattr(args, 'tz', None), + ) report['modules']['sahams'] = sahams_result else: report['modules']['sahams'] = {'warning': 'birth_datetime or planet_lons missing, skip saham calc'} @@ -5871,48 +5883,30 @@ def cmd_full_reading(args): def cmd_prashna(args): """Prashna 问事占星:基于提问时刻的即时星盘分析""" try: - from prashna import cast_prashna, calc_arudha, calc_sphutas, calc_life_sphutas, calc_sahams, analyze_lost_item, kunda_verify, calc_gulika_simple + from prashna_context import PrashnaContextError, build_prashna_context except ImportError: - # 尝试从同目录导入 - import importlib.util, os - spec = importlib.util.spec_from_file_location("prashna", os.path.join(os.path.dirname(__file__), "prashna.py")) - prashna_mod = importlib.util.module_from_spec(spec) - spec.loader.exec_module(prashna_mod) - cast_prashna = prashna_mod.cast_prashna - calc_arudha = prashna_mod.calc_arudha - calc_sphutas = prashna_mod.calc_sphutas - calc_life_sphutas = prashna_mod.calc_life_sphutas - calc_sahams = prashna_mod.calc_sahams - analyze_lost_item = prashna_mod.analyze_lost_item - kunda_verify = prashna_mod.kunda_verify - calc_gulika_simple = prashna_mod.calc_gulika_simple - - if args.mode == 'chart': - return cast_prashna(args.datetime, args.lat, args.lon) - - # 其他模式需要先铸盘获取行星位置 - chart = cast_prashna(args.datetime, args.lat, args.lon) - if 'error' in chart: - return chart - - asc_lon = chart['ascendant']['lon'] - p_lons = {n: d['lon'] for n, d in chart['planets'].items()} - - if args.mode == 'arudha': - return {'arudha_lagna': calc_arudha(asc_lon, p_lons), - 'ascendant': chart['ascendant']} - elif args.mode == 'sphutas': - return calc_sphutas(p_lons, 0) - elif args.mode == 'sahams': - return calc_sahams(p_lons, asc_lon) - elif args.mode == 'lost-item': - return analyze_lost_item(p_lons, asc_lon) - elif args.mode == 'life': - return calc_life_sphutas(asc_lon, p_lons.get('Moon',0), p_lons.get('Sun',0), 0) - elif args.mode == 'kunda': - return kunda_verify(asc_lon) - else: - return cast_prashna(args.datetime, args.lat, args.lon) + from scripts.prashna_context import PrashnaContextError, build_prashna_context + try: + context = build_prashna_context({ + "question_text": args.question_text, + "question_timestamp": args.datetime, + "lat": args.lat, + "lon": args.lon, + "timezone": args.timezone, + "ayanamsa": args.ayanamsa, + "node_mode": args.node_mode, + "location_convention": args.location_convention, + }) + except PrashnaContextError as exc: + return {"scope": "prashna_context", "status": "blocked", "reason": str(exc)} + if args.mode != "chart": + return { + "scope": "prashna", + "status": "blocked", + "reason": f"{args.mode} is blocked pending validated Prashna kernel implementation", + "prashna_context": context, + } + return context # ============================================================================ @@ -6176,9 +6170,14 @@ def main(): # 23. prashna (v3.9新增) p = sub.add_parser('prashna', help='Prashna问事占星(提问时刻星盘+Arudha+Sphuta+Sahams)') - p.add_argument('--datetime', required=True, help='提问时间 YYYY-MM-DD HH:MM') + p.add_argument('--datetime', required=True, help='提问时间 ISO-8601,例如 2026-07-12T12:00:00+08:00') + p.add_argument('--question-text', required=True, help='用户原始问事文本') p.add_argument('--lat', type=float, required=True, help='纬度') p.add_argument('--lon', type=float, required=True, help='经度') + p.add_argument('--timezone', required=True, help='UTC offset,例如 8 或 +08:00') + p.add_argument('--ayanamsa', default='lahiri') + p.add_argument('--node-mode', default='mean', choices=['mean', 'true']) + p.add_argument('--location-convention', default='wgs84', choices=['wgs84']) p.add_argument('--mode', default='chart', choices=['chart','arudha','sphutas','sahams','lost-item','life','kunda'], help='分析模式') # 24. double-transit-pac (v3.9新增) diff --git a/scripts/prashna_context.py b/scripts/prashna_context.py new file mode 100644 index 00000000..38a58caf --- /dev/null +++ b/scripts/prashna_context.py @@ -0,0 +1,105 @@ +"""Production Prashna chart context: question moment only, Swiss backend only.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +try: + from scripts.domain_calculation_service import CalculationError, compute_chart +except ModuleNotFoundError: # pragma: no cover - CLI execution path + from domain_calculation_service import CalculationError, compute_chart +try: + from scripts.gulika import calculate_gulika +except ModuleNotFoundError: # pragma: no cover - CLI execution path + from gulika import calculate_gulika +try: + from scripts.prashna_sphuta import calculate_sphuta_evidence +except ModuleNotFoundError: # pragma: no cover - CLI execution path + from prashna_sphuta import calculate_sphuta_evidence + + +class PrashnaContextError(ValueError): + pass + + +def _timezone_offset(value: Any, moment: datetime) -> float: + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, str): + raw = value.strip().upper().replace("UTC", "") + try: + return float(raw) + except ValueError: + pass + if moment.tzinfo is not None: + offset = moment.utcoffset() + if offset is not None: + return offset.total_seconds() / 3600 + raise PrashnaContextError("timezone must be a numeric UTC offset or present in question_timestamp") + + +def build_prashna_context(payload: dict[str, Any]) -> dict[str, Any]: + required = ("question_text", "question_timestamp", "lat", "lon", "timezone") + missing = [field for field in required if payload.get(field) in (None, "")] + if missing: + raise PrashnaContextError(f"missing required Prashna fields: {', '.join(missing)}") + if str(payload.get("location_convention") or "wgs84").lower() != "wgs84": + raise PrashnaContextError("location_convention must be wgs84") + try: + moment = datetime.fromisoformat(str(payload["question_timestamp"]).replace("Z", "+00:00")) + except ValueError as exc: + raise PrashnaContextError("question_timestamp must be ISO-8601") from exc + tz = _timezone_offset(payload["timezone"], moment) + if moment.tzinfo is not None: + timestamp_tz = moment.utcoffset().total_seconds() / 3600 + if abs(timestamp_tz - tz) > 0.001: + raise PrashnaContextError("timezone conflicts with question_timestamp offset") + moment = moment.replace(tzinfo=None) + try: + chart = compute_chart({ + "year": moment.year, "month": moment.month, "day": moment.day, + "hour": moment.hour, "minute": moment.minute, "second": moment.second, + "lat": float(payload["lat"]), "lon": float(payload["lon"]), "tz": tz, + "ayanamsa": str(payload.get("ayanamsa") or "lahiri"), + "node_mode": str(payload.get("node_mode") or "mean"), + }) + except (CalculationError, ValueError, TypeError) as exc: + raise PrashnaContextError(f"Swiss Prashna chart blocked: {exc}") from exc + try: + gulika = calculate_gulika(moment, lat=float(payload["lat"]), lon=float(payload["lon"]), tz=tz) + except Exception as exc: + gulika = { + "status": "blocked", + "reason": f"gulika_supporting_indicator_failed:{type(exc).__name__}", + } + if gulika.get("status") == "partial": + longitudes = { + name: item.get("degree_raw", item.get("lon")) + for name, item in chart["planets"].items() + if isinstance(item, dict) + } + sphuta = calculate_sphuta_evidence( + ascendant_longitude=chart["ascendant"].get("degree_raw", chart["ascendant"].get("lon")), + planet_longitudes=longitudes, + gulika_longitude=gulika["longitude"], + ) + else: + sphuta = {"status": "blocked", "reason": "gulika_supporting_indicator_unavailable"} + return { + "scope": "prashna_context", + "status": "computed", + "question_text": str(payload["question_text"])[:500], + "question_timestamp": str(payload["question_timestamp"]), + "location": {"lat": float(payload["lat"]), "lon": float(payload["lon"]), "timezone": tz, "location_convention": "wgs84"}, + "ayanamsa": str(payload.get("ayanamsa") or "lahiri"), + "node_mode": str(payload.get("node_mode") or "mean"), + "chart_source": "swiss_ephemeris_backend", + "ascendant": chart["ascendant"], + "planets": chart["planets"], + "calculation_contract": chart["calculation_contract"], + "result_hash": chart["result_hash"], + "supporting_indicators": {"gulika": gulika, "sphuta": sphuta}, + "blocked_layers": ["Kunda", "Prashna verdict"], + "boundary": "No client-supplied planets or ascendant are accepted. Gulika and formula-only Sphuta are supporting-only pending external numeric parity; verdict layers remain blocked.", + } diff --git a/scripts/prashna_sphuta.py b/scripts/prashna_sphuta.py new file mode 100644 index 00000000..e7c1f552 --- /dev/null +++ b/scripts/prashna_sphuta.py @@ -0,0 +1,44 @@ +"""Formula-only Prasna Marga Sphuta evidence, without verdict interpretation.""" +from __future__ import annotations + +from typing import Any + + +def _norm(value: float) -> float: + return float(value) % 360.0 + + +def calculate_sphuta_evidence( + *, + ascendant_longitude: float, + planet_longitudes: dict[str, Any], + gulika_longitude: float, +) -> dict[str, Any]: + required = ("Sun", "Moon", "Rahu") + missing = [name for name in required if name not in planet_longitudes] + if missing: + return {"status": "blocked", "reason": "missing_sphuta_planets", "missing": missing} + asc = _norm(ascendant_longitude) + moon = _norm(planet_longitudes["Moon"]) + sun = _norm(planet_longitudes["Sun"]) + rahu = _norm(planet_longitudes["Rahu"]) + gulika = _norm(gulika_longitude) + trisphuta = _norm(asc + moon + gulika) + catusphuta = _norm(trisphuta + sun) + pancasphuta = _norm(catusphuta + rahu) + return { + "scope": "prasna_marga_sphuta_evidence", + "status": "partial", + "points": { + "trisphuta": trisphuta, + "catusphuta": catusphuta, + "pancasphuta": pancasphuta, + }, + "formula_trace": { + "trisphuta": "Lagna + Moon + Gulika", + "catusphuta": "Trisphuta + Sun", + "pancasphuta": "Catusphuta + Rahu", + }, + "rule_source": "references/prashna-complete-guide.md#3.2-3.3", + "boundary": "Formula-only supporting evidence. No health, event, or Prashna verdict is permitted without external numeric parity and adjudication rules.", + } diff --git a/scripts/saham_daynight.py b/scripts/saham_daynight.py new file mode 100644 index 00000000..38ebea39 --- /dev/null +++ b/scripts/saham_daynight.py @@ -0,0 +1,41 @@ +"""Swiss Ephemeris sunrise/sunset evidence for Saham day/night formula selection.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +import swisseph as swe + + +class SahamDayNightError(ValueError): + pass + + +def determine_daytime(moment: datetime, *, lat: float, lon: float, tz: float) -> dict[str, Any]: + if not -90 <= float(lat) <= 90 or not -180 <= float(lon) <= 180: + raise SahamDayNightError("invalid WGS84 latitude/longitude") + local = moment.replace(tzinfo=None) + jd = swe.julday(local.year, local.month, local.day, local.hour + local.minute / 60 + local.second / 3600 - float(tz)) + geopos = (float(lon), float(lat), 0.0) + rise_status, rise = swe.rise_trans(jd - 1.0, swe.SUN, swe.CALC_RISE, geopos) + set_status, sunset = swe.rise_trans(jd - 1.0, swe.SUN, swe.CALC_SET, geopos) + if rise_status != 0 or set_status != 0: + raise SahamDayNightError("sunrise_or_sunset_unavailable_for_location_date") + sunrise_jd, sunset_jd = rise[0], sunset[0] + # Normalize the next daily events around the queried instant. + while sunrise_jd > jd: + sunrise_jd -= 1.0 + while sunset_jd > jd: + sunset_jd -= 1.0 + is_day = sunrise_jd <= jd < sunset_jd if sunrise_jd < sunset_jd else not (sunset_jd <= jd < sunrise_jd) + return { + "scope": "saham_daynight_swiss", + "status": "computed", + "is_daytime": is_day, + "julian_day_ut": jd, + "sunrise_jd_ut": sunrise_jd, + "sunset_jd_ut": sunset_jd, + "method": "swisseph.rise_trans", + "boundary": "Formula-specific +30 degree exceptions must be applied by the Saham rule layer, not inferred from house placement.", + } diff --git a/scripts/tajika_kernel.py b/scripts/tajika_kernel.py new file mode 100644 index 00000000..14c01431 --- /dev/null +++ b/scripts/tajika_kernel.py @@ -0,0 +1,79 @@ +"""Strict seven-planet Tajika aspect kernel. + +This module deliberately exposes only the auditable interaction layer. Named +Tajika chains remain blocked until their classical definitions have golden +cases; it never treats nodes as Tajika planets. +""" + +from __future__ import annotations + +from typing import Any + + +SEVEN_PLANETS = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn") +DEEPTAMSA = {"Sun": 15.0, "Moon": 12.0, "Mars": 8.0, "Mercury": 7.0, "Jupiter": 9.0, "Venus": 7.0, "Saturn": 9.0} +ASPECT_ANGLES = (0.0, 60.0, 90.0, 120.0, 180.0) + + +def _signed_angle(value: float) -> float: + return (value + 180.0) % 360.0 - 180.0 + + +def _nearest_aspect(delta: float) -> tuple[float, float]: + candidates = [] + for aspect in ASPECT_ANGLES: + for target in ({0.0} if aspect in (0.0, 180.0) else {aspect, -aspect}): + candidates.append((target, _signed_angle(delta - target))) + return min(candidates, key=lambda item: abs(item[1])) + + +def calculate_tajika_interactions(planets: dict[str, dict[str, Any]]) -> dict[str, Any]: + missing = [planet for planet in SEVEN_PLANETS if planet not in planets or "longitude" not in planets[planet] or "speed" not in planets[planet]] + if missing: + return { + "scope": "tajika_seven_planet_kernel", + "status": "blocked", + "reason": "longitude_and_speed_required_for_all_seven_planets", + "missing": missing, + "nodes_excluded": True, + } + interactions = [] + for index, left in enumerate(SEVEN_PLANETS): + for right in SEVEN_PLANETS[index + 1:]: + left_lon, right_lon = float(planets[left]["longitude"]) % 360, float(planets[right]["longitude"]) % 360 + aspect, residual = _nearest_aspect(right_lon - left_lon) + orb = (DEEPTAMSA[left] + DEEPTAMSA[right]) / 2.0 + if abs(residual) > orb: + continue + relative_speed = float(planets[right]["speed"]) - float(planets[left]["speed"]) + future_residual = _signed_angle(residual + relative_speed) + applying = abs(future_residual) < abs(residual) + interactions.append({ + "planets": [left, right], + "aspect": abs(aspect), + "residual": round(residual, 6), + "average_deeptamsa": orb, + "motion": "applying" if applying else "separating", + "within_deeptamsa": True, + }) + return { + "scope": "tajika_seven_planet_kernel", + "status": "partial", + "nodes_excluded": True, + "interactions": interactions, + "candidate_yogas": [ + { + "name": "Ithasala_candidate" if row["motion"] == "applying" else "Easarapha_candidate", + "planets": row["planets"], + "aspect": row["aspect"], + "residual": row["residual"], + "average_deeptamsa": row["average_deeptamsa"], + "motion": row["motion"], + "rule_source": "references/tajika-yoga-complete-guide.md#2.1-2.2", + "status": "partial", + } + for row in interactions + ], + "blocked_named_yogas": ["Nakta", "Yamaya", "Manahoo", "Kamboola", "Ithasala/Easarapha adjudication"], + "boundary": "Candidate labels are derived only from seven-planet aspect, Deeptamsa and applying/separating evidence. Full named-yoga chains and event verdicts remain blocked pending classic golden cases.", + } diff --git a/tests/test_full_reading_saham_contract.py b/tests/test_full_reading_saham_contract.py new file mode 100644 index 00000000..db7fbd57 --- /dev/null +++ b/tests/test_full_reading_saham_contract.py @@ -0,0 +1,15 @@ +from pathlib import Path + + +def test_full_reading_derives_saham_datetime_from_standard_chart_args() -> None: + source = (Path(__file__).resolve().parents[1] / "scripts" / "jyotish_engine.py").read_text(encoding="utf-8") + section = source[source.index("# ── Step 4.8: Tajika Yogas + Sahams"):source.index("# ── Step 4.9:")] + assert "birth_dt = _birth_datetime_from_args(args)" in section + assert "getattr(args, 'birth_datetime'" not in section + + +def test_full_reading_supplies_actual_speeds_to_tajika_kernel() -> None: + source = (Path(__file__).resolve().parents[1] / "scripts" / "jyotish_engine.py").read_text(encoding="utf-8") + section = source[source.index("# ── Step 4.8: Tajika Yogas + Sahams"):source.index("# ── Step 4.9:")] + assert '"longitude": item.get("degree_raw", item.get("lon"))' in section + assert '"speed": item.get("speed")' in section diff --git a/tests/test_gulika.py b/tests/test_gulika.py new file mode 100644 index 00000000..0fa09021 --- /dev/null +++ b/tests/test_gulika.py @@ -0,0 +1,17 @@ +from datetime import datetime + +from scripts.gulika import GHATIKA_END, calculate_gulika + + +def test_gulika_uses_prasna_marga_weekday_table() -> None: + assert GHATIKA_END[6] == {"day": 26, "night": 10} + assert GHATIKA_END[0] == {"day": 22, "night": 6} + + +def test_gulika_returns_sidereal_segment_ascendant_with_audit_trace() -> None: + result = calculate_gulika(datetime(1990, 6, 15, 12, 0), lat=39.9042, lon=116.4074, tz=8) + + assert result["status"] == "partial" + assert 0 <= result["longitude"] < 360 + assert result["ghatika_end"] in range(0, 31) + assert result["rule_source"].endswith("#3.5") diff --git a/tests/test_prashna_context.py b/tests/test_prashna_context.py new file mode 100644 index 00000000..6603e532 --- /dev/null +++ b/tests/test_prashna_context.py @@ -0,0 +1,32 @@ +from datetime import datetime + +import pytest + +from scripts.prashna_context import PrashnaContextError, build_prashna_context + + +def test_prashna_context_uses_backend_chart_from_question_moment(): + packet = build_prashna_context({ + "question_text": "Will this proceed?", + "question_timestamp": "2026-07-12T12:00:00+08:00", + "lat": 39.9042, + "lon": 116.4074, + "timezone": 8, + "ayanamsa": "lahiri", + "node_mode": "mean", + "location_convention": "wgs84", + }) + + assert packet["status"] == "computed" + assert packet["chart_source"] == "swiss_ephemeris_backend" + assert packet["ascendant"]["degree"] >= 0 + assert "Sun" in packet["planets"] + assert "question_timestamp" in packet + + +def test_prashna_context_rejects_missing_time_or_non_wgs84_location(): + base = {"question_text": "x", "lat": 1, "lon": 1, "timezone": 0} + with pytest.raises(PrashnaContextError, match="question_timestamp"): + build_prashna_context(base) + with pytest.raises(PrashnaContextError, match="location_convention"): + build_prashna_context({**base, "question_timestamp": "2026-01-01T00:00:00+00:00", "location_convention": "unknown"}) diff --git a/tests/test_prashna_entry_contract.py b/tests/test_prashna_entry_contract.py new file mode 100644 index 00000000..62185733 --- /dev/null +++ b/tests/test_prashna_entry_contract.py @@ -0,0 +1,62 @@ +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +from scripts.jyotish_api_server import BadRequest, JyotishAPIHandler + + +ROOT = Path(__file__).resolve().parents[1] + + +def test_cli_prashna_uses_question_moment_swiss_context_only(): + result = subprocess.run([ + sys.executable, "scripts/jyotish_engine.py", "prashna", + "--datetime", "2026-07-12T12:00:00+08:00", "--question-text", "Test question", + "--lat", "39.9042", "--lon", "116.4074", "--timezone", "8", + ], cwd=ROOT, text=True, capture_output=True, timeout=30, check=True) + payload = json.loads(result.stdout) + + assert payload["status"] == "computed" + assert payload["chart_source"] == "swiss_ephemeris_backend" + assert payload["supporting_indicators"]["gulika"]["status"] == "partial" + assert payload["supporting_indicators"]["sphuta"]["status"] == "partial" + assert "Kunda" in payload["blocked_layers"] + + +def test_cli_prashna_blocks_legacy_approximation_modes(): + result = subprocess.run([ + sys.executable, "scripts/jyotish_engine.py", "prashna", + "--datetime", "2026-07-12T12:00:00+08:00", "--question-text", "Test question", + "--lat", "39.9042", "--lon", "116.4074", "--timezone", "8", "--mode", "sphutas", + ], cwd=ROOT, text=True, capture_output=True, timeout=30, check=True) + payload = json.loads(result.stdout) + + assert payload["status"] == "blocked" + assert "sphutas" in payload["reason"] + + +def test_api_prashna_rejects_client_planets_and_computes_context(): + handler = JyotishAPIHandler.__new__(JyotishAPIHandler) + body = { + "question_text": "Test question", "question_timestamp": "2026-07-12T12:00:00+08:00", + "lat": 39.9042, "lon": 116.4074, "timezone": 8, + "ayanamsa": "lahiri", "node_mode": "mean", "location_convention": "wgs84", + } + result = handler._compute_prashna(body) + assert result["prashna_context"]["chart_source"] == "swiss_ephemeris_backend" + with pytest.raises(BadRequest, match="forbidden"): + handler._compute_prashna({**body, "planets": {"Sun": 0}}) + + +def test_web_prashna_collects_question_context_not_natal_chart(): + source = (ROOT / "jyotish-app" / "main.js").read_text(encoding="utf-8") + markup = (ROOT / "jyotish-app" / "index.html").read_text(encoding="utf-8") + + assert "question_timestamp: timestamp.value" in source + assert "planets: chartData?.planets" not in source + assert "asc_degree: chartData?.ascendant" not in source + for field in ("prashna-timestamp", "prashna-lat", "prashna-lon", "prashna-timezone"): + assert field in markup diff --git a/tests/test_prashna_sphuta.py b/tests/test_prashna_sphuta.py new file mode 100644 index 00000000..469902db --- /dev/null +++ b/tests/test_prashna_sphuta.py @@ -0,0 +1,23 @@ +from scripts.prashna_sphuta import calculate_sphuta_evidence + + +def test_sphuta_formula_evidence_uses_exact_gulika_input() -> None: + result = calculate_sphuta_evidence( + ascendant_longitude=10, + planet_longitudes={"Moon": 20, "Sun": 30, "Rahu": 40}, + gulika_longitude=50, + ) + + assert result["status"] == "partial" + assert result["points"] == {"trisphuta": 80.0, "catusphuta": 110.0, "pancasphuta": 150.0} + + +def test_sphuta_evidence_blocks_missing_required_planet() -> None: + result = calculate_sphuta_evidence( + ascendant_longitude=10, + planet_longitudes={"Moon": 20, "Sun": 30}, + gulika_longitude=50, + ) + + assert result["status"] == "blocked" + assert result["missing"] == ["Rahu"] diff --git a/tests/test_saham_daynight.py b/tests/test_saham_daynight.py new file mode 100644 index 00000000..8ad32924 --- /dev/null +++ b/tests/test_saham_daynight.py @@ -0,0 +1,13 @@ +from datetime import datetime + +from scripts.saham_daynight import determine_daytime + + +def test_swiss_daynight_does_not_use_solar_house_proxy(): + noon = determine_daytime(datetime(2026, 7, 12, 12, 0), lat=39.9042, lon=116.4074, tz=8) + midnight = determine_daytime(datetime(2026, 7, 12, 0, 0), lat=39.9042, lon=116.4074, tz=8) + + assert noon["status"] == "computed" + assert noon["is_daytime"] is True + assert midnight["is_daytime"] is False + assert noon["method"] == "swisseph.rise_trans" diff --git a/tests/test_tajika_kernel.py b/tests/test_tajika_kernel.py new file mode 100644 index 00000000..14e41981 --- /dev/null +++ b/tests/test_tajika_kernel.py @@ -0,0 +1,33 @@ +from scripts.tajika_kernel import calculate_tajika_interactions + + +def _seven(): + return { + "Sun": {"longitude": 0, "speed": 1.0}, "Moon": {"longitude": 49, "speed": 13.0}, + "Mars": {"longitude": 180, "speed": 0.5}, "Mercury": {"longitude": 260, "speed": 1.2}, + "Jupiter": {"longitude": 310, "speed": 0.08}, "Venus": {"longitude": 130, "speed": 1.0}, + "Saturn": {"longitude": 220, "speed": 0.03}, "Rahu": {"longitude": 60, "speed": -0.05}, + } + + +def test_kernel_detects_cross_sign_aspect_and_excludes_nodes(): + result = calculate_tajika_interactions(_seven()) + + pair = next(row for row in result["interactions"] if row["planets"] == ["Sun", "Moon"]) + assert pair["aspect"] == 60.0 + assert pair["motion"] == "applying" + candidate = next(row for row in result["candidate_yogas"] if row["planets"] == ["Sun", "Moon"]) + assert candidate["name"] == "Ithasala_candidate" + assert candidate["status"] == "partial" + assert result["nodes_excluded"] is True + assert all("Rahu" not in row["planets"] for row in result["interactions"]) + + +def test_kernel_blocks_missing_speed_instead_of_guessing_motion(): + planets = _seven() + del planets["Venus"]["speed"] + + result = calculate_tajika_interactions(planets) + + assert result["status"] == "blocked" + assert "Venus" in result["missing"]