年运附表补上整宫、Tripataki 和特殊点公式轨迹。黄经沿用已算好的星盘,不改原有 Saham 数值。

Co-Authored-By: Grok (agent loop)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jesse-ux
2026-10-22 23:11:48 +08:00
co-authored by Grok (agent loop) Cursor
parent 6b906f85c4
commit 9b2e6aa0b4
9 changed files with 560 additions and 23 deletions
+73
View File
@@ -0,0 +1,73 @@
"""Whole-sign houses for an annual chart that is already computed."""
from __future__ import annotations
from typing import Any
SIGNS = (
"Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
)
SIGN_LORDS = {
"Aries": "Mars", "Taurus": "Venus", "Gemini": "Mercury", "Cancer": "Moon",
"Leo": "Sun", "Virgo": "Mercury", "Libra": "Venus", "Scorpio": "Mars",
"Sagittarius": "Jupiter", "Capricorn": "Saturn", "Aquarius": "Saturn", "Pisces": "Jupiter",
}
def _longitude(value: Any) -> float | None:
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
return float(value) % 360.0
def build_annual_whole_sign_houses(chart: dict | None) -> dict[str, Any]:
"""Twelve whole-sign houses from the annual ascendant already on the chart."""
chart = chart if isinstance(chart, dict) else {}
ascendant = chart.get("ascendant") if isinstance(chart.get("ascendant"), dict) else {}
planets = chart.get("planets") if isinstance(chart.get("planets"), dict) else {}
asc_lon = _longitude(ascendant.get("degree_raw"))
if asc_lon is None:
asc_lon = _longitude(ascendant.get("lon") if ascendant.get("lon") is not None else ascendant.get("degree"))
if asc_lon is None:
return {
"schema": "jyotish.annual_whole_sign_houses.v1",
"status": "blocked",
"reason": "annual_ascendant_longitude_missing",
"houses": [],
"source_longitudes": {},
}
asc_index = int(asc_lon // 30.0) % 12
placed: dict[int, list[str]] = {index: [] for index in range(12)}
source_longitudes = {}
for name in ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu"):
data = planets.get(name)
if not isinstance(data, dict):
continue
longitude = _longitude(data.get("degree_raw"))
if longitude is None:
longitude = _longitude(data.get("degree"))
if longitude is None:
continue
if name in ("Sun", "Moon") and data.get("degree_raw") is not None and not isinstance(data.get("degree_raw"), bool):
source_longitudes[name] = float(data["degree_raw"]) % 360.0
placed[int(longitude // 30.0) % 12].append(name)
houses = []
for house in range(1, 13):
sign_index = (asc_index + house - 1) % 12
sign = SIGNS[sign_index]
houses.append({
"house": house,
"sign": sign,
"lord": SIGN_LORDS[sign],
"occupants": placed[sign_index],
"cusp_longitude": sign_index * 30.0,
})
return {
"schema": "jyotish.annual_whole_sign_houses.v1",
"status": "partial_verified",
"house_system": "whole_sign",
"houses": houses,
"source_longitudes": source_longitudes,
}
+96
View File
@@ -0,0 +1,96 @@
"""Formula traces for Sahams that were already calculated.
The trace copies the stored longitude. It does not replace it.
"""
from __future__ import annotations
from typing import Any
# (result key, day (p1, p2), night (p1, p2) or None when day and night match)
# _saham(p1, p2) = Asc + p2 - p1.
_LOCAL = (
("karya_saham", ("Moon", "Mars"), ("Mars", "Moon")),
("vivah_saham", ("Mars", "Venus"), None),
("rajya_saham", ("Jupiter", "Sun"), None),
("shatru_saham", ("Saturn", "Mars"), None),
("labha_saham", ("Moon", "Jupiter"), None),
("parakrama_saham", ("Sun", "Mars"), None),
("putra_saham", ("Moon", "Jupiter"), None),
("jnana_saham", ("Mercury", "Jupiter"), None),
("raja_saham", ("Moon", "Sun"), None),
("yasha_saham", ("Sun", "Jupiter"), None),
("karma_saham", ("Mars", "Saturn"), None),
("bandhu_saham", ("Mercury", "Mars"), None),
("matri_saham", ("Venus", "Moon"), None),
("pitri_saham", ("Saturn", "Sun"), None),
("janma_saham", ("Sun", "Asc"), None),
("mrityu_saham", ("Mars", "Saturn"), None),
("rogha_saham", ("Saturn", "Mercury"), None),
("aarogya_saham", ("Saturn", "Jupiter"), None),
("bhraatri_saham", ("Moon", "Mars"), None),
("ghataka_saham", ("Mars", "Rahu"), None),
("paradesa_saham", ("Moon", "Saturn"), None),
("parabharya_saham", ("Mars", "Venus"), None),
("dhan_saham", ("Venus", "Jupiter"), None),
("maya_saham", ("Ketu", "Rahu"), None),
("moksha_saham", ("Jupiter", "Ketu"), None),
("buddha_saham", ("Jupiter", "Mercury"), None),
("shastra_saham", ("Ketu", "Mars"), None),
("kala_saham", ("Sun", "Saturn"), None),
("shakti_saham", ("Moon", "Venus"), None),
("bhrigu_saham", ("Jupiter", "Venus"), None),
("sundara_saham", ("Mercury", "Venus"), None),
("jnati_saham", ("Saturn", "Mars"), None),
("artha_saham", ("Venus", "Sun"), None),
("dharma_saham", ("Saturn", "Jupiter"), None),
("sangrama_saham", ("Rahu", "Mars"), None),
)
def _rule_text(meta: dict) -> str:
operands = meta.get("formula_operands") or ()
text = " + ".join(str(part).replace("_", " ") for part in operands) if operands else str(meta.get("rule_name") or "rule")
if meta.get("zodiacal_order_exception_applied"):
text += " (+30 deg)"
return text
def attach_formula_traces(
results: dict[str, Any],
*,
is_day: bool,
formula_meta: dict[str, dict] | None = None,
) -> list[dict]:
"""Add ``formula_traces`` without changing any existing Saham longitude."""
formula_meta = formula_meta or {}
traces = []
punya = formula_meta.get("punya_saham")
if punya and isinstance(results.get("punya_saham"), dict):
traces.append({
"name": results["punya_saham"].get("name") or "Punya Saham",
"key": "punya_saham",
"rule": _rule_text(punya),
"phase": "day" if is_day else "night",
"longitude": results["punya_saham"].get("longitude"),
})
for key, day_pair, night_pair in _LOCAL:
item = results.get(key)
if not isinstance(item, dict) or not isinstance(item.get("longitude"), (int, float)):
continue
meta = formula_meta.get(key)
if meta:
rule = _rule_text(meta)
else:
pair = day_pair if is_day or night_pair is None else night_pair
rule = f"Asc + {pair[1]} - {pair[0]}"
traces.append({
"name": item.get("name") or key,
"key": key,
"rule": rule,
"phase": "day" if is_day else "night",
"longitude": item.get("longitude"),
})
results["formula_traces"] = traces
return traces
+104
View File
@@ -0,0 +1,104 @@
"""Tripataki placement grid and Vedha rows from an annual chart.
Longitudes are the ones already on our chart. This module does not call an
ephemeris and does not import PyJHora.
"""
from __future__ import annotations
from typing import Any
SIGNS = (
"Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
)
VEDHA_TARGETS = {
"Moon": ("Sun", "Mars", "Mercury", "Venus", "Saturn"),
"Lagna": ("Jupiter", "Ketu"),
"Sun": ("Moon", "Mars", "Mercury", "Venus", "Saturn"),
"Mars": ("Sun", "Moon", "Mercury", "Venus", "Saturn"),
"Mercury": ("Sun", "Moon", "Mars", "Venus", "Saturn"),
"Jupiter": ("Ketu",),
"Venus": ("Sun", "Moon", "Mars", "Mercury", "Saturn"),
"Saturn": ("Sun", "Moon", "Mars", "Mercury", "Venus"),
}
def build_tripataki_chakra(
planet_longitudes: dict[str, Any],
ascendant_longitude: float | int | None,
) -> dict[str, Any]:
if not isinstance(planet_longitudes, dict):
return _blocked("annual_planet_longitudes_missing")
try:
if isinstance(ascendant_longitude, bool):
raise TypeError
ascendant = float(ascendant_longitude) % 360
except (TypeError, ValueError):
return _blocked("annual_ascendant_longitude_missing")
placements = [_placement("Lagna", ascendant, is_lagna=True)]
for planet in ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu"):
try:
raw = planet_longitudes[planet]
if isinstance(raw, bool):
raise TypeError
placements.append(_placement(planet, float(raw) % 360))
except (KeyError, TypeError, ValueError):
continue
sign_cells = []
for sign_index, sign in enumerate(SIGNS):
members = [row["body"] for row in placements if row["sign_index"] == sign_index]
sign_cells.append({"sign_index": sign_index, "sign": sign, "bodies": members})
available = {row["body"] for row in placements}
vedha_rows = []
for target, sources in VEDHA_TARGETS.items():
ready_sources = [source for source in sources if source in available]
missing_sources = [source for source in sources if source not in available]
vedha_rows.append({
"target": target,
"sources": list(sources),
"available_sources": ready_sources,
"missing_sources": missing_sources,
"status": "partial_verified" if target in available and not missing_sources else "blocked",
})
return {
"schema": "pl9.tripataki_chakra.v1",
"status": "partial_verified" if len(placements) >= 8 else "blocked",
"reason": None if len(placements) >= 8 else "fewer_than_eight_bodies",
"placements": placements,
"sign_cells": sign_cells,
"vedha_rows": vedha_rows,
"source_longitudes": {
name: next(row["longitude"] for row in placements if row["body"] == name)
for name in ("Sun", "Moon")
if any(row["body"] == name for row in placements)
},
}
def _placement(body: str, longitude: float, *, is_lagna: bool = False) -> dict[str, Any]:
sign_index = int(longitude // 30) % 12
return {
"body": body,
"longitude": longitude,
"sign_index": sign_index,
"sign": SIGNS[sign_index],
"degree_in_sign": longitude % 30,
"is_lagna": is_lagna,
}
def _blocked(reason: str) -> dict[str, Any]:
return {
"schema": "pl9.tripataki_chakra.v1",
"status": "blocked",
"reason": reason,
"placements": [],
"sign_cells": [],
"vedha_rows": [],
"source_longitudes": {},
}
+2
View File
@@ -65,6 +65,8 @@ def build_annual_tajika_pack(
"tajika_named_yogas": _tajika_named_yogas_field(solar_report),
"tajika_strength": _tajika_strength_field(solar_report if isinstance(solar_report, dict) else {}),
"sahams": sahams,
"annual_houses": solar_report.get("annual_houses") if isinstance(solar_report, dict) else None,
"tripataki_chakra": solar_report.get("tripataki_chakra") if isinstance(solar_report, dict) else None,
"mudda_dasha": mudda_dasha,
"patyayini_dasha": patyayini_dasha,
"monthly_windows": _monthly_windows(mudda_dasha),
+21 -11
View File
@@ -2619,12 +2619,20 @@ def render_pl9_parity_markdown(packet: dict) -> str:
tripataki_placements = _list(tripataki.get('placements'))
tripataki_vedha = _list(tripataki.get('vedha_rows'))
if tripataki_placements:
lines.extend([
'### Tripataki Chakra 年盘落点',
'',
'| Body | Sign | Degree | Longitude |',
'|------|------|--------|-----------|',
])
if _lang == 'en':
lines.extend([
'### Tripataki Chakra placements',
'',
'| Body | Sign | Degree | Longitude |',
'|------|------|--------|-----------|',
])
else:
lines.extend([
'### Tripataki Chakra 年盘落点',
'',
'| 星体 | 星座 | 落座度数 | 黄经 |',
'|------|------|----------|------|',
])
for row in tripataki_placements:
if not isinstance(row, dict):
continue
@@ -2634,15 +2642,17 @@ def render_pl9_parity_markdown(packet: dict) -> str:
)
lines.append('')
if tripataki_vedha:
lines.extend([
'| Target | Vedha sources |',
'|--------|---------------|',
])
if _lang == 'en':
lines.extend(['| Target | Vedha sources |', '|--------|---------------|'])
joiner = ', '
else:
lines.extend(['| 目标 | Vedha 来源 |', '|------|-------------|'])
joiner = '、'
for row in tripataki_vedha:
if not isinstance(row, dict):
continue
sources = row.get('sources') if isinstance(row.get('sources'), list) else []
lines.append(f"| {_cell(row.get('target'))} | {_cell('、'.join(map(str, sources)))} |")
lines.append(f"| {_cell(_token(row.get('target')))} | {_cell(joiner.join(_token(item) for item in sources))} |")
lines.append('')
monthly_pack = _dict(annual.get('monthly_chart_pack'))
monthly_rows = _list(monthly_pack.get('rows'))
+112 -12
View File
@@ -166,9 +166,12 @@ SECTIONS: tuple[tuple[str, str], ...] = (
("Current Dasha Convergence", "当前多系统收敛"),
("Chara Dasha Period Durations", "Chara 大运时长"),
("Narayana Method Comparison", "Narayana 口径对照"),
("Annual Return House Framework", "年运整宫框架"),
("Annual Return Calculation Contract", "年运返照计算约定"),
("Tajika Aspect and Yoga Candidates", "Tajika 相位与瑜伽候选"),
("Tripataki Chakra Detail", "Tripataki 轮盘明细"),
("Annual Tajika Strength", "年度 Tajika 力量"),
("Annual Saham Formula Trace", "年度特殊点公式轨迹"),
("Shadbala Subcomponent", "六维力量分项"),
("Shadbala Result", "六维力量结果"),
("House Components and Physical Coordinates", "宫位分量与坐标"),
@@ -202,6 +205,7 @@ SECTIONS: tuple[tuple[str, str], ...] = (
("Pancha Pakshi", "五鸟活动"),
("Natal Vivah Saham", "本命 Vivah Saham"),
("Natal Saham Formula Basis", "本命特殊点公式依据"),
("Natal Saham Formula Trace", "本命特殊点公式轨迹"),
("Arudha / UL / A10", "Arudha、Upapada 与 A10"),
("Jaimini Calculation Details", "Jaimini 计算细节"),
("Graha Pada", "Graha Pada"),
@@ -225,10 +229,6 @@ SECTIONS: tuple[tuple[str, str], ...] = (
)
FORBIDDEN_TITLES = (
"Annual Return House Framework",
"Tripataki Chakra Detail",
"Annual Saham Formula Trace",
"Natal Saham Formula Trace",
"Annual Chart Numeric Data",
"Bhava Bala Relative Score Components",
"Score Extremes",
@@ -850,6 +850,102 @@ def _narayana(packet: dict, language: str) -> str:
])
def _pack_data(field: Any) -> Any:
if isinstance(field, dict) and "data" in field and isinstance(field.get("data"), (dict, list)):
return field["data"]
return field
def _occupants_cell(names: Any, language: str) -> str:
if isinstance(names, list) and names:
return planet_list(names, language) or ("无" if language == "zh" else "None")
if isinstance(names, list):
return "无" if language == "zh" else "None"
return names if isinstance(names, str) else ("无" if language == "zh" else "None")
def _phase_word(phase: Any, language: str) -> str | None:
if phase == "day":
return "日间" if language == "zh" else "Day"
if phase == "night":
return "夜间" if language == "zh" else "Night"
return None
def _annual_houses(packet: dict, language: str) -> str:
pack = _first(packet, ("timing_and_predictive_systems", "annual_tajika_pack")) or {}
data = _pack_data(pack.get("annual_houses") if isinstance(pack, dict) else None)
houses = data.get("houses") if isinstance(data, dict) else None
rows = []
for item in houses or []:
if not isinstance(item, dict):
continue
rows.append({
"house": item.get("house"),
"sign": item.get("sign"),
"lord": item.get("lord"),
"occupant": _occupants_cell(item.get("occupants"), language),
})
return _rows_from(rows, language, [("house", "text"), ("sign", "sign"), ("lord", "planet"), ("occupant", "text")])
def _tripataki(packet: dict, language: str) -> str:
pack = _first(packet, ("timing_and_predictive_systems", "annual_tajika_pack")) or {}
data = _pack_data(pack.get("tripataki_chakra") if isinstance(pack, dict) else None)
if not isinstance(data, dict):
return _blank(language, ["point", "sign", "degree", "longitude"])
placements = []
for item in data.get("placements") or []:
if isinstance(item, dict):
placements.append({
"point": item.get("body"),
"sign": item.get("sign"),
"degree": None if item.get("degree_in_sign") is None else round(float(item["degree_in_sign"]), 4),
"longitude": None if item.get("longitude") is None else round(float(item["longitude"]), 4),
})
vedha = []
for item in data.get("vedha_rows") or []:
if isinstance(item, dict):
vedha.append({
"target": item.get("target"),
"planet": item.get("available_sources") or item.get("sources"),
"status": item.get("status"),
})
parts = [
_rows_from(placements, language, [("point", "text"), ("sign", "sign"), ("degree", "text"), ("longitude", "text")]),
_rows_from(vedha, language, [("target", "planet"), ("planet", "planets"), ("status", "text")]),
]
return "\n\n".join(parts)
def _trace_table(traces: Any, language: str) -> str:
rows = []
if isinstance(traces, list):
for item in traces:
if not isinstance(item, dict):
continue
rows.append({
"name": item.get("name"),
"rule": item.get("rule"),
"phase": _phase_word(item.get("phase"), language),
"longitude": item.get("longitude"),
})
return _rows_from(rows, language, [("name", "text"), ("rule", "text"), ("phase", "text"), ("longitude", "text")])
def _annual_saham_trace(packet: dict, language: str) -> str:
pack = _first(packet, ("timing_and_predictive_systems", "annual_tajika_pack")) or {}
sahams = _pack_data(pack.get("sahams") if isinstance(pack, dict) else None)
traces = sahams.get("formula_traces") if isinstance(sahams, dict) else None
return _trace_table(traces, language)
def _natal_saham_trace(packet: dict, language: str) -> str:
module = _first(packet, ("advanced_systems", "sahams")) or {}
traces = module.get("formula_traces") if isinstance(module, dict) else None
return _trace_table(traces, language)
def _annual(packet: dict, language: str) -> str:
pack = _first(packet, ("timing_and_predictive_systems", "annual_tajika_pack")) or {}
solar = pack.get("solar_return") if isinstance(pack, dict) else {}
@@ -2398,9 +2494,12 @@ _BUILDERS = {
"Current Dasha Convergence": _convergence,
"Chara Dasha Period Durations": _chara,
"Narayana Method Comparison": _narayana,
"Annual Return Calculation Contract": _annual,
"Tajika Aspect and Yoga Candidates": _tajika_candidates,
"Annual Tajika Strength": _tajika_strength,
"Annual Return House Framework": _annual_houses,
"Annual Return Calculation Contract": _annual,
"Tajika Aspect and Yoga Candidates": _tajika_candidates,
"Tripataki Chakra Detail": _tripataki,
"Annual Tajika Strength": _tajika_strength,
"Annual Saham Formula Trace": _annual_saham_trace,
}
@@ -2408,11 +2507,11 @@ def _chapter_sections() -> list[list[str]]:
order = [title for title, _zh in SECTIONS]
groups = [
order[:14],
order[14:17],
order[17:32],
order[32:43],
order[43:59],
order[59:],
order[14:20],
order[20:35],
order[35:46],
order[46:63],
order[63:],
]
return groups
@@ -2455,6 +2554,7 @@ def _render_named(packet: dict, language: str, title: str) -> str:
"Pancha Pakshi": lambda: _missing_module(language, ["name", "value", "status"]),
"Natal Vivah Saham": lambda: _vivah(packet, language),
"Natal Saham Formula Basis": lambda: _saham_basis(packet, language),
"Natal Saham Formula Trace": lambda: _natal_saham_trace(packet, language),
"Arudha / UL / A10": lambda: _special_points(packet, language),
"Jaimini Calculation Details": lambda: _jaimini_details(packet, language),
"Graha Pada": lambda: _graha_pada(packet, language),
+14
View File
@@ -539,6 +539,20 @@ def solar_return_full_report(
'age': sr['age'],
}
planet_lons = sr['planet_lons']
try:
from annual_native.annual_houses import build_annual_whole_sign_houses
from annual_native.tripataki_chakra import build_tripataki_chakra
except ImportError:
from scripts.annual_native.annual_houses import build_annual_whole_sign_houses
from scripts.annual_native.tripataki_chakra import build_tripataki_chakra
annual_chart = sr.get('chart') if isinstance(sr.get('chart'), dict) else {}
result['annual_houses'] = build_annual_whole_sign_houses(annual_chart)
raw_longitudes = {}
for name, data in (annual_chart.get('planets') or {}).items():
if isinstance(data, dict) and isinstance(data.get('degree_raw'), (int, float)) and not isinstance(data.get('degree_raw'), bool):
raw_longitudes[name] = data['degree_raw']
ascendant = annual_chart.get('ascendant') if isinstance(annual_chart.get('ascendant'), dict) else {}
result['tripataki_chakra'] = build_tripataki_chakra(raw_longitudes, ascendant.get('degree_raw'))
sr_planets = sr.get('chart', {}).get('planets', {})
tajika_planets = {
pn: {
+21
View File
@@ -1793,6 +1793,27 @@ def calc_all_sahams(planet_lons: Dict[str, float],
sangrama_lon = _saham(rahu_lon, mars_lon)
results['sangrama_saham'] = _saham_dict(sangrama_lon, 'Sangrama Saham', '战斗点——竞争、挑战、胜利')
formula_meta: Dict[str, Dict] = {}
for result_key, rule_name in (
('punya_saham', 'Punya_Saham'),
('yasha_saham', 'Yashas_Saham'),
('karma_saham', 'Karma_Saham'),
):
try:
traced = _formula_saham_trace(rule_name, planet_lons, asc_lon, is_day, computed_formula_sahams)
except KeyError:
continue
formula_meta[result_key] = {
'rule_name': traced.get('rule_name'),
'formula_operands': traced.get('formula_operands'),
'formula_variant': traced.get('formula_variant'),
'zodiacal_order_exception_applied': traced.get('zodiacal_order_exception_applied'),
}
try:
from annual_native.saham_traces import attach_formula_traces
except ImportError:
from scripts.annual_native.saham_traces import attach_formula_traces
attach_formula_traces(results, is_day=is_day, formula_meta=formula_meta)
return results