Harden prashna production entrypoints

This commit is contained in:
732642856
2026-07-16 21:54:34 +08:00
parent 0e15b05308
commit 3f86f6d77c
16 changed files with 626 additions and 51 deletions
+9 -1
View File
@@ -613,11 +613,19 @@
<div class="form-group prashna-question">
<label>具体问题</label>
<input type="text" id="prashna-question" maxlength="120" placeholder="例如:这个工作机会是否值得争取?">
<label>提问时刻(当地)</label>
<input type="datetime-local" id="prashna-timestamp">
<label>提问地点纬度 / 经度 / 时区</label>
<div class="form-row">
<input type="number" step="0.0001" id="prashna-lat" placeholder="纬度">
<input type="number" step="0.0001" id="prashna-lon" placeholder="经度">
<input type="number" step="0.5" id="prashna-timezone" placeholder="UTC 时区,例如 8">
</div>
</div>
<button class="btn-primary prashna-run" id="btn-run-prashna">问事分析</button>
</div>
<div id="prashna-result" class="prashna-result">
<p>Prashna 使用提问当下时刻与当前星盘数据判断具体问题,适合一次只问一个清晰问题</p>
<p>Prashna 使用提问当下时刻与地点由后端排盘;不使用本命盘替代问事盘</p>
</div>
<div id="prashna-case-workspace" class="prashna-case-workspace"></div>
</div>
+19 -4
View File
@@ -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 = '<p class="prashna-error">请填写一个明确问题。</p>';
return;
}
if (!timestamp?.value || !lat?.value || !lon?.value || !timezone?.value) {
result.innerHTML = '<p class="prashna-error">请填写提问时刻、纬度、经度与 UTC 时区。</p>';
return;
}
if (questionText.length > 120) {
result.innerHTML = '<p class="prashna-error">问题请控制在 120 字以内。</p>';
return;
@@ -7198,11 +7210,14 @@ function renderPrashnaTab(chartData) {
result.innerHTML = '<p>正在铸造 Prashna 问事盘...</p>';
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);
+67
View File
@@ -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.",
}
+22
View File
@@ -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,
+45 -46
View File
@@ -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新增)
+105
View File
@@ -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.",
}
+44
View File
@@ -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.",
}
+41
View File
@@ -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.",
}
+79
View File
@@ -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.",
}
+15
View File
@@ -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
+17
View File
@@ -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")
+32
View File
@@ -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"})
+62
View File
@@ -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
+23
View File
@@ -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"]
+13
View File
@@ -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"
+33
View File
@@ -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"]