From 9b2e6aa0b4af47edd98f2cebd0d11780bae3dd10 Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Thu, 22 Oct 2026 23:11:48 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B9=B4=E8=BF=90=E9=99=84=E8=A1=A8=E8=A1=A5?= =?UTF-8?q?=E4=B8=8A=E6=95=B4=E5=AE=AB=E3=80=81Tripataki=20=E5=92=8C?= =?UTF-8?q?=E7=89=B9=E6=AE=8A=E7=82=B9=E5=85=AC=E5=BC=8F=E8=BD=A8=E8=BF=B9?= =?UTF-8?q?=E3=80=82=E9=BB=84=E7=BB=8F=E6=B2=BF=E7=94=A8=E5=B7=B2=E7=AE=97?= =?UTF-8?q?=E5=A5=BD=E7=9A=84=E6=98=9F=E7=9B=98=EF=BC=8C=E4=B8=8D=E6=94=B9?= =?UTF-8?q?=E5=8E=9F=E6=9C=89=20Saham=20=E6=95=B0=E5=80=BC=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Grok (agent loop) Co-authored-by: Cursor --- scripts/annual_native/annual_houses.py | 73 +++++++++++++ scripts/annual_native/saham_traces.py | 96 +++++++++++++++++ scripts/annual_native/tripataki_chakra.py | 104 ++++++++++++++++++ scripts/annual_tajika_pack.py | 2 + scripts/pl9_reader_export.py | 32 ++++-- scripts/report_render/parity_chapters.py | 124 +++++++++++++++++++--- scripts/solar_return.py | 14 +++ scripts/tajika.py | 21 ++++ tests/test_annual_native_geometry.py | 117 ++++++++++++++++++++ 9 files changed, 560 insertions(+), 23 deletions(-) create mode 100644 scripts/annual_native/annual_houses.py create mode 100644 scripts/annual_native/saham_traces.py create mode 100644 scripts/annual_native/tripataki_chakra.py create mode 100644 tests/test_annual_native_geometry.py diff --git a/scripts/annual_native/annual_houses.py b/scripts/annual_native/annual_houses.py new file mode 100644 index 00000000..4141a714 --- /dev/null +++ b/scripts/annual_native/annual_houses.py @@ -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, + } diff --git a/scripts/annual_native/saham_traces.py b/scripts/annual_native/saham_traces.py new file mode 100644 index 00000000..751dd2dc --- /dev/null +++ b/scripts/annual_native/saham_traces.py @@ -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 diff --git a/scripts/annual_native/tripataki_chakra.py b/scripts/annual_native/tripataki_chakra.py new file mode 100644 index 00000000..f39e7c59 --- /dev/null +++ b/scripts/annual_native/tripataki_chakra.py @@ -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": {}, + } diff --git a/scripts/annual_tajika_pack.py b/scripts/annual_tajika_pack.py index 95bbd97e..14e61ef3 100755 --- a/scripts/annual_tajika_pack.py +++ b/scripts/annual_tajika_pack.py @@ -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), diff --git a/scripts/pl9_reader_export.py b/scripts/pl9_reader_export.py index 80966529..2ec89e6d 100644 --- a/scripts/pl9_reader_export.py +++ b/scripts/pl9_reader_export.py @@ -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')) diff --git a/scripts/report_render/parity_chapters.py b/scripts/report_render/parity_chapters.py index 4930893d..095fe154 100644 --- a/scripts/report_render/parity_chapters.py +++ b/scripts/report_render/parity_chapters.py @@ -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), diff --git a/scripts/solar_return.py b/scripts/solar_return.py index 98d4dc29..62d36a46 100644 --- a/scripts/solar_return.py +++ b/scripts/solar_return.py @@ -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: { diff --git a/scripts/tajika.py b/scripts/tajika.py index aaad8a6c..43acd73b 100644 --- a/scripts/tajika.py +++ b/scripts/tajika.py @@ -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 diff --git a/tests/test_annual_native_geometry.py b/tests/test_annual_native_geometry.py new file mode 100644 index 00000000..5be18a91 --- /dev/null +++ b/tests/test_annual_native_geometry.py @@ -0,0 +1,117 @@ +"""T6: whole-sign annual houses, Tripataki, and Saham traces use our longitudes.""" + +from __future__ import annotations + +import json +from datetime import datetime + +from scripts.annual_native.annual_houses import build_annual_whole_sign_houses +from scripts.annual_native.tripataki_chakra import build_tripataki_chakra +from scripts.jyotish_engine import compute_chart_data +from scripts.report_render.parity_chapters import render_parity_chapters +from scripts.tajika import calc_all_sahams +from tests.report_english_fictional import fictional_case + +LIMIT = 1e-6 + + +def _delta(left: float, right: float) -> float: + return abs(((left - right + 180.0) % 360.0) - 180.0) + + +def _chart(): + birth = fictional_case(0) + chart, *_rest = compute_chart_data( + birth["year"], birth["month"], birth["day"], birth["hour"], birth["minute"], + birth["lat"], birth["lon"], birth["tz"], + node_mode=birth["node_mode"], second=birth["second"], ayanamsa_name=birth["ayanamsa"], + ) + return birth, chart + + +def test_new_longitudes_match_the_chart() -> None: + _birth, chart = _chart() + houses = build_annual_whole_sign_houses(chart) + assert houses["status"] == "partial_verified" + assert houses["house_system"] == "whole_sign" + assert len(houses["houses"]) == 12 + raw = { + name: float(chart["planets"][name]["degree_raw"]) % 360.0 + for name in ("Sun", "Moon") + } + for name, longitude in raw.items(): + assert _delta(houses["source_longitudes"][name], longitude) <= LIMIT + asc = float(chart["ascendant"]["degree_raw"]) + tripataki = build_tripataki_chakra( + {name: data["degree_raw"] for name, data in chart["planets"].items() if isinstance(data, dict) and "degree_raw" in data}, + asc, + ) + assert tripataki["status"] == "partial_verified" + for name, longitude in raw.items(): + assert _delta(tripataki["source_longitudes"][name], longitude) <= LIMIT + sun = next(row for row in tripataki["placements"] if row["body"] == "Sun") + assert _delta(sun["longitude"], raw["Sun"]) <= LIMIT + + +def test_saham_traces_keep_the_stored_longitude() -> None: + birth, chart = _chart() + asc = chart["ascendant"] + asc_lon = float(asc.get("lon") if isinstance(asc.get("lon"), (int, float)) else asc["degree_raw"]) + planet_lons = { + name: float(data.get("degree", data["degree_raw"])) + for name, data in chart["planets"].items() + if isinstance(data, dict) and "degree" in data + } + result = calc_all_sahams( + planet_lons, + asc_lon, + datetime(birth["year"], birth["month"], birth["day"], birth["hour"], birth["minute"]), + lat=birth["lat"], + lon=birth["lon"], + tz=birth["tz"], + ) + assert result.get("status") != "blocked" + traces = result["formula_traces"] + assert len(traces) >= 30 + for trace in traces: + assert trace["longitude"] == result[trace["key"]]["longitude"] + without = {key: value for key, value in result.items() if key != "formula_traces"} + assert len(json.dumps(without, ensure_ascii=False, default=str)) > 800 + is_day = result["daynight_evidence"]["is_daytime"] + karya = next(item for item in traces if item["key"] == "karya_saham") + first, second = (("Moon", "Mars") if is_day else ("Mars", "Moon")) + expected = round((asc_lon + planet_lons[second] - planet_lons[first]) % 360.0, 4) + assert karya["longitude"] == expected + assert karya["rule"] == f"Asc + {second} - {first}" + + +def test_four_sections_render_in_both_languages() -> None: + packet = { + "worksheets": { + "advanced_systems": {"sahams": {"formula_traces": [ + {"name": "Punya Saham", "key": "punya_saham", "rule": "Asc + Moon - Sun", "phase": "day", "longitude": 12.5}, + ]}}, + "timing_and_predictive_systems": {"annual_tajika_pack": { + "annual_houses": {"houses": [ + {"house": 1, "sign": "Aries", "lord": "Mars", "occupants": []}, + ]}, + "tripataki_chakra": { + "placements": [{"body": "Sun", "sign": "Leo", "degree_in_sign": 1.25, "longitude": 121.25}], + "vedha_rows": [{"target": "Sun", "available_sources": ["Moon"], "status": "partial_verified"}], + }, + "sahams": {"data": {"formula_traces": [ + {"name": "Vivah Saham", "key": "vivah_saham", "rule": "Asc + Venus - Mars", "phase": "night", "longitude": 40.0}, + ]}}, + }}, + } + } + chinese = render_parity_chapters(packet, "zh") + english = render_parity_chapters(packet, "en") + assert "年运整宫框架" in chinese and "无" in chinese + assert "Annual Return House Framework" in english and "None" in english + assert "Tripataki 轮盘明细" in chinese and "Tripataki Chakra Detail" in english + assert "年度特殊点公式轨迹" in chinese and "夜间" in chinese + assert "Annual Saham Formula Trace" in english and "Night" in english + assert "本命特殊点公式轨迹" in chinese and "日间" in chinese + assert "Natal Saham Formula Trace" in english and "Day" in english + assert "blocked" not in chinese.lower()