feat(upstream): merge a6f47abd engine, MCP, and orchestrator
Three-way-merge calculation modules and pl9-export into the product fork while keeping commercial API routes, Raman ayanamsa, and the consultation contract as a keypath superset. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+275
-12
@@ -28,6 +28,14 @@ The starting lord is determined by the Moon's Nakshatra at birth.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from ashtottari_rule_profiles import (
|
||||
RULE_FAMILY_MOON_NAKSHATRA_PLUS_PAKSHA,
|
||||
RULE_FAMILY_RAHU_FROM_LAGNA_LORD,
|
||||
SHUKLA_PAKSHA_NAKSHATRAS,
|
||||
KRISHNA_PAKSHA_NAKSHATRAS,
|
||||
evaluate_moon_nakshatra_plus_paksha_rule,
|
||||
evaluate_rahu_from_lagna_lord_rule,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
@@ -43,9 +51,38 @@ NAKSHATRAS = [
|
||||
"Uttara Bhadrapada", "Revati"
|
||||
]
|
||||
|
||||
# Applicable Nakshatras for Ashtottari Dasha
|
||||
SHUKLA_PAKSHA_NAKSHATRAS = {0, 4, 6, 13, 20, 22} # Ashwini, Mrigashira, Punarvasu, Chitra, Shravana, Dhanishta
|
||||
KRISHNA_PAKSHA_NAKSHATRAS = {3, 5, 7, 8, 9, 26} # Rohini, Ardra, Pushya, Ashlesha, Magha, Revati
|
||||
# Candidate 28-nakshatra cycle used by the PL9 sample-admitted path.
|
||||
# This preserves the sample's Mula -> Mercury balance note while leaving the
|
||||
# legacy Moon-whitelist rule available for research comparison.
|
||||
_CANDIDATE_NAKSHATRA_STARTING_LORDS = {
|
||||
"Ardra": "Sun",
|
||||
"Punarvasu": "Sun",
|
||||
"Pushya": "Sun",
|
||||
"Ashlesha": "Sun",
|
||||
"Magha": "Moon",
|
||||
"Purva Phalguni": "Moon",
|
||||
"Uttara Phalguni": "Moon",
|
||||
"Hasta": "Mars",
|
||||
"Chitra": "Mars",
|
||||
"Swati": "Mars",
|
||||
"Vishakha": "Mars",
|
||||
"Anuradha": "Mercury",
|
||||
"Jyeshtha": "Mercury",
|
||||
"Mula": "Mercury",
|
||||
"Purva Ashadha": "Saturn",
|
||||
"Uttara Ashadha": "Saturn",
|
||||
"Shravana": "Saturn",
|
||||
"Dhanishta": "Jupiter",
|
||||
"Shatabhisha": "Jupiter",
|
||||
"Purva Bhadrapada": "Jupiter",
|
||||
"Uttara Bhadrapada": "Rahu",
|
||||
"Revati": "Rahu",
|
||||
"Ashwini": "Rahu",
|
||||
"Bharani": "Rahu",
|
||||
"Krittika": "Venus",
|
||||
"Rohini": "Venus",
|
||||
"Mrigashira": "Venus",
|
||||
}
|
||||
|
||||
# Ashtottari planetary sequence and year allotments (BPHS Ch.19)
|
||||
DASHA_SEQUENCE = [
|
||||
@@ -61,6 +98,17 @@ DASHA_SEQUENCE = [
|
||||
|
||||
TOTAL_CYCLE = 108 # years
|
||||
|
||||
_PLANET_ABBR = {
|
||||
"Sun": "Su",
|
||||
"Moon": "Mo",
|
||||
"Mars": "Ma",
|
||||
"Mercury": "Me",
|
||||
"Saturn": "Sa",
|
||||
"Jupiter": "Ju",
|
||||
"Rahu": "Ra",
|
||||
"Venus": "Ve",
|
||||
}
|
||||
|
||||
# Mapping from starting Nakshatra index to first dasha lord index in DASHA_SEQUENCE
|
||||
# Per BPHS Ch.19:
|
||||
# - Rohini (3), Ardra (5), Pushya (7), Ashlesha (8), Magha (9), Revati (26)
|
||||
@@ -99,10 +147,145 @@ def is_ashtottari_applicable(moon_nakshatra_index: int, is_shukla_paksha: bool)
|
||||
Returns:
|
||||
bool: True if Ashtottari Dasha applies.
|
||||
"""
|
||||
if is_shukla_paksha:
|
||||
return moon_nakshatra_index in SHUKLA_PAKSHA_NAKSHATRAS
|
||||
else:
|
||||
return moon_nakshatra_index in KRISHNA_PAKSHA_NAKSHATRAS
|
||||
applicable, _ = evaluate_moon_nakshatra_plus_paksha_rule(moon_nakshatra_index, is_shukla_paksha)
|
||||
return applicable
|
||||
|
||||
|
||||
def _moon_nakshatra_name(moon_nakshatra_index: int) -> str:
|
||||
if moon_nakshatra_index is None:
|
||||
return "unknown"
|
||||
try:
|
||||
return NAKSHATRAS[moon_nakshatra_index]
|
||||
except Exception:
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _candidate_nakshatra_starting_lord(moon_nakshatra_index: int) -> str:
|
||||
return _CANDIDATE_NAKSHATRA_STARTING_LORDS.get(_moon_nakshatra_name(moon_nakshatra_index), "Sun")
|
||||
|
||||
|
||||
def _candidate_lord_nakshatra_span(starting_planet: str) -> tuple[int, int] | tuple[None, None]:
|
||||
matching = [
|
||||
idx for idx, name in enumerate(NAKSHATRAS)
|
||||
if _CANDIDATE_NAKSHATRA_STARTING_LORDS.get(name) == starting_planet
|
||||
]
|
||||
if not matching:
|
||||
return None, None
|
||||
return matching[0], matching[-1]
|
||||
|
||||
|
||||
def _years_to_balance_parts(years: float) -> dict:
|
||||
total_days = max(years, 0.0) * 365.256364
|
||||
whole_years = int(total_days // 365.256364)
|
||||
remaining_days = total_days - whole_years * 365.256364
|
||||
whole_months = int(remaining_days // 30.0)
|
||||
residual_days = remaining_days - whole_months * 30.0
|
||||
day_count = int(round(residual_days))
|
||||
if residual_days > 0:
|
||||
day_count += 1
|
||||
if day_count >= 30:
|
||||
whole_months += day_count // 30
|
||||
day_count = day_count % 30
|
||||
if whole_months >= 12:
|
||||
whole_years += whole_months // 12
|
||||
whole_months = whole_months % 12
|
||||
return {
|
||||
"years": years,
|
||||
"years_whole": whole_years,
|
||||
"months_whole": whole_months,
|
||||
"days_whole": day_count,
|
||||
}
|
||||
|
||||
|
||||
def _years_to_balance_parts_360(years: float) -> dict:
|
||||
total_days = max(years, 0.0) * 360.0
|
||||
whole_years = int(total_days // 360.0)
|
||||
remaining_days = total_days - whole_years * 360.0
|
||||
whole_months = int(remaining_days // 30.0)
|
||||
residual_days = remaining_days - whole_months * 30.0
|
||||
day_count = int(round(residual_days))
|
||||
if residual_days > 0:
|
||||
day_count += 1
|
||||
if day_count >= 30:
|
||||
whole_months += day_count // 30
|
||||
day_count = day_count % 30
|
||||
if whole_months >= 12:
|
||||
whole_years += whole_months // 12
|
||||
whole_months = whole_months % 12
|
||||
return {
|
||||
"years": years,
|
||||
"years_whole": whole_years,
|
||||
"months_whole": whole_months,
|
||||
"days_whole": day_count,
|
||||
}
|
||||
|
||||
|
||||
def _calc_candidate_balance_and_birth_chain(moon_longitude: float, starting_planet: str) -> tuple[dict | None, dict | None]:
|
||||
start_nak_idx, end_nak_idx = _candidate_lord_nakshatra_span(starting_planet)
|
||||
if start_nak_idx is None or end_nak_idx is None:
|
||||
return None, None
|
||||
|
||||
one_star = 360.0 / 27.0
|
||||
segment_start = start_nak_idx * one_star
|
||||
segment_end = (end_nak_idx + 1) * one_star
|
||||
longitude = float(moon_longitude) % 360.0
|
||||
if not (segment_start <= longitude <= segment_end):
|
||||
return None, None
|
||||
|
||||
span = segment_end - segment_start
|
||||
elapsed = longitude - segment_start
|
||||
remaining_ratio = max(0.0, min(1.0, (segment_end - longitude) / span))
|
||||
md_years = next(lord["years"] for lord in DASHA_SEQUENCE if lord["planet"] == starting_planet)
|
||||
remaining_years = md_years * remaining_ratio
|
||||
|
||||
def _sequence_from(lord: str) -> list[str]:
|
||||
idx = next(i for i, item in enumerate(DASHA_SEQUENCE) if item["planet"] == lord)
|
||||
planets = [item["planet"] for item in DASHA_SEQUENCE]
|
||||
return planets[idx:] + planets[:idx]
|
||||
|
||||
def _child_periods(parent_lord: str, parent_duration_years: float) -> list[dict]:
|
||||
rows = []
|
||||
for child_lord in _sequence_from(parent_lord):
|
||||
child_years = parent_duration_years * next(
|
||||
item["years"] for item in DASHA_SEQUENCE if item["planet"] == child_lord
|
||||
) / TOTAL_CYCLE
|
||||
rows.append({"lord": child_lord, "years": child_years})
|
||||
return rows
|
||||
|
||||
elapsed_in_md = md_years - remaining_years
|
||||
hierarchy = [starting_planet]
|
||||
parent_lord = starting_planet
|
||||
parent_duration = md_years
|
||||
elapsed_within_parent = elapsed_in_md
|
||||
|
||||
for _depth in range(4):
|
||||
rows = _child_periods(parent_lord, parent_duration)
|
||||
cursor = 0.0
|
||||
selected = rows[-1]
|
||||
for row in rows:
|
||||
next_cursor = cursor + row["years"]
|
||||
if cursor <= elapsed_within_parent < next_cursor:
|
||||
selected = row
|
||||
elapsed_within_parent = elapsed_within_parent - cursor
|
||||
break
|
||||
cursor = next_cursor
|
||||
hierarchy.append(selected["lord"])
|
||||
parent_lord = selected["lord"]
|
||||
parent_duration = selected["years"]
|
||||
|
||||
balance = {
|
||||
"planet": starting_planet,
|
||||
"segment_start_nakshatra": NAKSHATRAS[start_nak_idx],
|
||||
"segment_end_nakshatra": NAKSHATRAS[end_nak_idx],
|
||||
"segment_span_nakshatras": end_nak_idx - start_nak_idx + 1,
|
||||
"display_calendar": "360_day_traditional",
|
||||
**_years_to_balance_parts_360(remaining_years),
|
||||
}
|
||||
birth_chain = {
|
||||
"lords": hierarchy,
|
||||
"compact": "-".join(_PLANET_ABBR.get(lord, lord[:2]) for lord in hierarchy),
|
||||
}
|
||||
return balance, birth_chain
|
||||
|
||||
|
||||
def _build_major_periods(start_lord_idx: int, birth_date: datetime) -> list:
|
||||
@@ -130,6 +313,50 @@ def _build_major_periods(start_lord_idx: int, birth_date: datetime) -> list:
|
||||
return periods
|
||||
|
||||
|
||||
def _sequence_from_lord(starting_planet: str) -> list[dict]:
|
||||
start_idx = next(
|
||||
idx for idx, lord in enumerate(DASHA_SEQUENCE) if lord["planet"] == starting_planet
|
||||
)
|
||||
return DASHA_SEQUENCE[start_idx:] + DASHA_SEQUENCE[:start_idx]
|
||||
|
||||
|
||||
def _build_subperiods(parent_period: dict, level_key: str) -> list[dict]:
|
||||
start_dt = datetime.fromisoformat(parent_period["start_date"])
|
||||
current_dt = start_dt
|
||||
child_periods = []
|
||||
for lord in _sequence_from_lord(parent_period["planet"]):
|
||||
child_years = float(parent_period["years"]) * float(lord["years"]) / float(TOTAL_CYCLE)
|
||||
end_dt = current_dt + timedelta(days=child_years * 365.25)
|
||||
child_periods.append({
|
||||
"level": level_key,
|
||||
"planet": lord["planet"],
|
||||
"lord": lord["planet"],
|
||||
"years": child_years,
|
||||
"start_date": current_dt.isoformat(),
|
||||
"end_date": end_dt.isoformat(),
|
||||
"status": "parameter_sensitive",
|
||||
"derivation": "native_ashtottari_recursive_proportional_variant",
|
||||
})
|
||||
current_dt = end_dt
|
||||
if child_periods:
|
||||
child_periods[-1]["end_date"] = parent_period["end_date"]
|
||||
return child_periods
|
||||
|
||||
|
||||
def _attach_recursive_subperiods(major_periods: list[dict]) -> tuple[list[dict], list[dict]]:
|
||||
all_antardashas = []
|
||||
all_pratyantardashas = []
|
||||
for period in major_periods:
|
||||
antardasha = _build_subperiods(period, "antardasha")
|
||||
period["antardasha"] = antardasha
|
||||
all_antardashas.extend(antardasha)
|
||||
for child in antardasha:
|
||||
pratyantardasha = _build_subperiods(child, "pratyantardasha")
|
||||
child["pratyantardasha"] = pratyantardasha
|
||||
all_pratyantardashas.extend(pratyantardasha)
|
||||
return all_antardashas, all_pratyantardashas
|
||||
|
||||
|
||||
def calculate_ashtottari_dasha(birth_info: dict) -> dict:
|
||||
"""
|
||||
Calculate Ashtottari Dasha for a native.
|
||||
@@ -151,16 +378,23 @@ def calculate_ashtottari_dasha(birth_info: dict) -> dict:
|
||||
moon_idx = birth_info.get("moon_nakshatra_index")
|
||||
is_shukla = birth_info.get("is_shukla_paksha", True)
|
||||
birth_dt = birth_info.get("birth_datetime")
|
||||
moon_longitude = birth_info.get("moon_longitude")
|
||||
rule_family = birth_info.get("applicability_rule_family") or RULE_FAMILY_MOON_NAKSHATRA_PLUS_PAKSHA
|
||||
|
||||
if isinstance(birth_dt, str):
|
||||
birth_dt = datetime.fromisoformat(birth_dt)
|
||||
if birth_dt is None:
|
||||
birth_dt = datetime.now()
|
||||
|
||||
applicable = is_ashtottari_applicable(moon_idx, is_shukla)
|
||||
if rule_family == RULE_FAMILY_RAHU_FROM_LAGNA_LORD:
|
||||
applicable, applicability_reason = evaluate_rahu_from_lagna_lord_rule(birth_info)
|
||||
starting_planet = _candidate_nakshatra_starting_lord(moon_idx)
|
||||
else:
|
||||
applicable, applicability_reason = evaluate_moon_nakshatra_plus_paksha_rule(moon_idx, is_shukla)
|
||||
starting_planet = None
|
||||
|
||||
if not applicable:
|
||||
return {
|
||||
result = {
|
||||
"applicable": False,
|
||||
"major": [],
|
||||
"current": None,
|
||||
@@ -168,11 +402,31 @@ def calculate_ashtottari_dasha(birth_info: dict) -> dict:
|
||||
"starting_planet": None,
|
||||
"reason": f"Moon Nakshatra '{NAKSHATRAS[moon_idx]}' does not qualify for Ashtottari Dasha under {'Shukla' if is_shukla else 'Krishna'} Paksha.",
|
||||
}
|
||||
if rule_family == RULE_FAMILY_RAHU_FROM_LAGNA_LORD:
|
||||
result.update({
|
||||
"execution_status": "blocked",
|
||||
"confidence_status": "blocked",
|
||||
"verification_status": "unverified",
|
||||
})
|
||||
return result
|
||||
|
||||
start_lord_idx = STARTING_LORD_MAP.get(moon_idx, 0)
|
||||
starting_planet = DASHA_SEQUENCE[start_lord_idx]["planet"]
|
||||
if starting_planet is None:
|
||||
start_lord_idx = STARTING_LORD_MAP.get(moon_idx, 0)
|
||||
starting_planet = DASHA_SEQUENCE[start_lord_idx]["planet"]
|
||||
else:
|
||||
start_lord_idx = next(
|
||||
idx for idx, lord in enumerate(DASHA_SEQUENCE) if lord["planet"] == starting_planet
|
||||
)
|
||||
|
||||
major_periods = _build_major_periods(start_lord_idx, birth_dt)
|
||||
antardasha_periods, pratyantardasha_periods = _attach_recursive_subperiods(major_periods)
|
||||
balance_at_birth = None
|
||||
dasha_at_birth = None
|
||||
if moon_longitude is not None and rule_family == RULE_FAMILY_RAHU_FROM_LAGNA_LORD:
|
||||
balance_at_birth, dasha_at_birth = _calc_candidate_balance_and_birth_chain(
|
||||
moon_longitude=float(moon_longitude),
|
||||
starting_planet=starting_planet,
|
||||
)
|
||||
|
||||
# Determine current period. Major periods repeat every 108 years; older natives
|
||||
# should still return a current period instead of None after the first cycle.
|
||||
@@ -195,13 +449,22 @@ def calculate_ashtottari_dasha(birth_info: dict) -> dict:
|
||||
break
|
||||
cumulative += years
|
||||
|
||||
return {
|
||||
result = {
|
||||
"applicable": True,
|
||||
"major": major_periods,
|
||||
"antardasha": antardasha_periods,
|
||||
"pratyantardasha": pratyantardasha_periods,
|
||||
"current": current_period,
|
||||
"total_cycle": TOTAL_CYCLE,
|
||||
"starting_planet": starting_planet,
|
||||
}
|
||||
if rule_family == RULE_FAMILY_RAHU_FROM_LAGNA_LORD:
|
||||
result.update({
|
||||
"execution_status": "executed",
|
||||
"confidence_status": "parameter_sensitive",
|
||||
"verification_status": "unverified",
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Shared Ashtottari applicability rule-family helpers.
|
||||
|
||||
This module does not declare a single verified truth. It only centralizes the
|
||||
repo's current competing rule families so runtime surfaces can expose them
|
||||
consistently and audits can compare them without hidden drift.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
RULE_FAMILY_MOON_NAKSHATRA_PLUS_PAKSHA = "moon_nakshatra_plus_paksha"
|
||||
RULE_FAMILY_RAHU_NOT_IN_KENDRA = "rahu_not_in_kendra"
|
||||
RULE_FAMILY_RAHU_FROM_LAGNA_LORD = "rahu_from_lagna_lord_kendra_trikona_plus_paksha_daynight"
|
||||
|
||||
SHUKLA_PAKSHA_NAKSHATRAS = {0, 4, 6, 13, 20, 22}
|
||||
KRISHNA_PAKSHA_NAKSHATRAS = {3, 5, 7, 8, 9, 26}
|
||||
ALLOWED_RAHU_HOUSES_FROM_LAGNA_LORD = {4, 5, 7, 9, 10}
|
||||
KENDRA_HOUSES = {1, 4, 7, 10}
|
||||
|
||||
|
||||
def evaluate_moon_nakshatra_plus_paksha_rule(
|
||||
moon_nakshatra_index: int | None,
|
||||
is_shukla_paksha: bool | None,
|
||||
) -> tuple[bool, str]:
|
||||
if moon_nakshatra_index is None or is_shukla_paksha is None:
|
||||
return False, "Moon-nakshatra/Paksha rule needs moon_nakshatra_index and is_shukla_paksha."
|
||||
allowed = SHUKLA_PAKSHA_NAKSHATRAS if is_shukla_paksha else KRISHNA_PAKSHA_NAKSHATRAS
|
||||
if moon_nakshatra_index in allowed:
|
||||
return True, "Moon-nakshatra/Paksha rule matched the current whitelist."
|
||||
return False, "Moon-nakshatra/Paksha rule did not match the current whitelist."
|
||||
|
||||
|
||||
def evaluate_rahu_not_in_kendra_rule(rahu_house: int | None) -> tuple[bool, str]:
|
||||
if rahu_house is None:
|
||||
return False, "Rahu-not-in-Kendra rule needs rahu_house."
|
||||
if rahu_house not in KENDRA_HOUSES:
|
||||
return True, f"Rahu-not-in-Kendra rule matched: Rahu house {rahu_house} is outside 1/4/7/10."
|
||||
return False, f"Rahu-not-in-Kendra rule blocked: Rahu house {rahu_house} is in 1/4/7/10."
|
||||
|
||||
|
||||
def evaluate_rahu_from_lagna_lord_rule(birth_info: dict[str, Any]) -> tuple[bool, str]:
|
||||
birth_is_daytime = birth_info.get("birth_is_daytime")
|
||||
is_shukla = birth_info.get("is_shukla_paksha")
|
||||
rahu_sign_index = birth_info.get("rahu_sign_index")
|
||||
lagna_lord_sign_index = birth_info.get("lagna_lord_sign_index")
|
||||
missing = [
|
||||
name for name, value in (
|
||||
("birth_is_daytime", birth_is_daytime),
|
||||
("is_shukla_paksha", is_shukla),
|
||||
("rahu_sign_index", rahu_sign_index),
|
||||
("lagna_lord_sign_index", lagna_lord_sign_index),
|
||||
)
|
||||
if value is None
|
||||
]
|
||||
if missing:
|
||||
return False, f"Candidate Ashtottari rule needs {', '.join(missing)}."
|
||||
|
||||
if birth_is_daytime and not is_shukla:
|
||||
pass
|
||||
elif (not birth_is_daytime) and is_shukla:
|
||||
pass
|
||||
else:
|
||||
return False, (
|
||||
"Candidate Ashtottari gate expects day birth in Krishna Paksha or "
|
||||
"night birth in Shukla Paksha."
|
||||
)
|
||||
|
||||
house_from_lagna_lord = ((int(rahu_sign_index) - int(lagna_lord_sign_index)) % 12) + 1
|
||||
if house_from_lagna_lord not in ALLOWED_RAHU_HOUSES_FROM_LAGNA_LORD:
|
||||
return False, (
|
||||
"Rahu is not in Kendra/Trikona from the Lagna lord under the frozen candidate rule."
|
||||
)
|
||||
return True, (
|
||||
f"Candidate rule matched: {('day' if birth_is_daytime else 'night')} birth, "
|
||||
f"{'Krishna' if not is_shukla else 'Shukla'} Paksha, Rahu {house_from_lagna_lord}th from Lagna lord."
|
||||
)
|
||||
|
||||
|
||||
def evaluate_rule_family(rule_family: str, payload: dict[str, Any]) -> tuple[bool, str]:
|
||||
if rule_family == RULE_FAMILY_MOON_NAKSHATRA_PLUS_PAKSHA:
|
||||
return evaluate_moon_nakshatra_plus_paksha_rule(
|
||||
payload.get("moon_nakshatra_index"),
|
||||
payload.get("is_shukla_paksha"),
|
||||
)
|
||||
if rule_family == RULE_FAMILY_RAHU_NOT_IN_KENDRA:
|
||||
return evaluate_rahu_not_in_kendra_rule(payload.get("rahu_house"))
|
||||
if rule_family == RULE_FAMILY_RAHU_FROM_LAGNA_LORD:
|
||||
return evaluate_rahu_from_lagna_lord_rule(payload)
|
||||
return False, f"Unknown Ashtottari rule family: {rule_family}"
|
||||
@@ -16,7 +16,9 @@ from ayanamsa_utils import (
|
||||
DEFAULT_AYANAMSA_NAME,
|
||||
UnsupportedAyanamsaError,
|
||||
apply_ayanamsa,
|
||||
is_supported_ayanamsa_name,
|
||||
normalize_ayanamsa_name,
|
||||
supported_ayanamsa_names,
|
||||
)
|
||||
from dasha_analyzer import build_dasha_timeline, lon_to_nakshatra
|
||||
from jyotish_engine import SIGNS, compute_chart_data
|
||||
@@ -285,6 +287,10 @@ def compute_transit_longitude(
|
||||
local_dt = datetime.strptime(reference_date[:10], "%Y-%m-%d").replace(hour=12)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise CalculationError("reference_date must be YYYY-MM-DD") from exc
|
||||
if not is_supported_ayanamsa_name(ayanamsa):
|
||||
raise CalculationError(
|
||||
f"unsupported ayanamsa: {ayanamsa!r}. Supported values: {', '.join(supported_ayanamsa_names())}"
|
||||
)
|
||||
ayanamsa_name = normalize_ayanamsa_name(ayanamsa)
|
||||
with _SWISSEPH_LOCK:
|
||||
apply_ayanamsa(ayanamsa_name, swe)
|
||||
|
||||
+137
-5
@@ -11,6 +11,10 @@ try:
|
||||
from saham_daynight import determine_daytime
|
||||
except ImportError:
|
||||
from scripts.saham_daynight import determine_daytime
|
||||
try:
|
||||
from ayanamsa_utils import ayanamsa_display_name, normalize_ayanamsa_name, sidereal_flags
|
||||
except ImportError:
|
||||
from scripts.ayanamsa_utils import ayanamsa_display_name, normalize_ayanamsa_name, sidereal_flags
|
||||
|
||||
|
||||
# Monday=0, matching datetime.weekday(). Values are the end of Saturn's share
|
||||
@@ -37,10 +41,40 @@ SATURN_PART_START = {
|
||||
6: {"day": 6, "night": 2},
|
||||
}
|
||||
|
||||
# Planet indices follow the local seven-graha ordering: Sun through Saturn.
|
||||
# Each row assigns the eight daylight/nighttime shares for a weekday where
|
||||
# Python's datetime.weekday() is Monday=0.
|
||||
DAY_RULERS = (
|
||||
(1, 2, 3, 4, 5, 6, -1, 0),
|
||||
(2, 3, 4, 5, 6, -1, 0, 1),
|
||||
(3, 4, 5, 6, -1, 0, 1, 2),
|
||||
(4, 5, 6, -1, 0, 1, 2, 3),
|
||||
(5, 6, -1, 0, 1, 2, 3, 4),
|
||||
(6, -1, 0, 1, 2, 3, 4, 5),
|
||||
(0, 1, 2, 3, 4, 5, 6, -1),
|
||||
)
|
||||
NIGHT_RULERS = (
|
||||
(5, 6, -1, 0, 1, 2, 3, 4),
|
||||
(6, -1, 0, 1, 2, 3, 4, 5),
|
||||
(-1, 0, 1, 2, 3, 4, 5, 6),
|
||||
(0, 1, 2, 3, 4, 5, 6, -1),
|
||||
(1, 2, 3, 4, 5, 6, -1, 0),
|
||||
(2, 3, 4, 5, 6, -1, 0, 1),
|
||||
(4, 5, 6, -1, 0, 1, 2, 3),
|
||||
)
|
||||
TEMPORAL_UPAGRAHAS = {
|
||||
"Kaala": (0, "middle"),
|
||||
"Mrityu": (2, "middle"),
|
||||
"Artha_Praharaka": (3, "middle"),
|
||||
"Yama_Ghantaka": (4, "middle"),
|
||||
"Gulika": (6, "begin"),
|
||||
"Maandi": (6, "middle"),
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
def _sidereal_ascendant(jd_ut: float, lat: float, lon: float, ayanamsa: str = "raman") -> float:
|
||||
flags = sidereal_flags(swe, ayanamsa)
|
||||
cusps, ascmc = swe.houses_ex(jd_ut, lat, lon, b"P", flags)
|
||||
return float(ascmc[0]) % 360
|
||||
|
||||
|
||||
@@ -51,8 +85,10 @@ def calculate_gulika(
|
||||
lon: float,
|
||||
tz: float,
|
||||
method: str = "saturn_part_start",
|
||||
ayanamsa: str = "raman",
|
||||
) -> dict[str, Any]:
|
||||
"""Return Gulika from local moment/location using Swiss sunrise and sunset."""
|
||||
ayanamsa_name = normalize_ayanamsa_name(ayanamsa)
|
||||
daynight = determine_daytime(moment, lat=lat, lon=lon, tz=tz)
|
||||
is_day = bool(daynight["is_daytime"])
|
||||
period = "day" if is_day else "night"
|
||||
@@ -73,7 +109,7 @@ def calculate_gulika(
|
||||
else:
|
||||
raise ValueError("method must be saturn_part_start or legacy_ghatika_end")
|
||||
segment_jd = start_jd + (end_jd - start_jd) * segment_fraction
|
||||
longitude = _sidereal_ascendant(segment_jd, float(lat), float(lon))
|
||||
longitude = _sidereal_ascendant(segment_jd, float(lat), float(lon), ayanamsa_name)
|
||||
return {
|
||||
"scope": "gulika_prasna_marga",
|
||||
"status": "partial",
|
||||
@@ -89,7 +125,103 @@ def calculate_gulika(
|
||||
"ghatika_end": ghatika_end,
|
||||
"segment_jd_ut": segment_jd,
|
||||
"daynight_evidence": daynight,
|
||||
"ayanamsa": "lahiri",
|
||||
"ayanamsa": ayanamsa_name,
|
||||
"ayanamsa_display": ayanamsa_display_name(ayanamsa_name),
|
||||
"rule_source": "references/prashna-complete-guide.md#3.5",
|
||||
"boundary": "PyJHora-aligned Saturn-part-start variant. Numeric parity is evidence only and does not enable Prashna verdict layers.",
|
||||
}
|
||||
|
||||
|
||||
def _temporal_upagraha(
|
||||
moment: datetime,
|
||||
*,
|
||||
lat: float,
|
||||
lon: float,
|
||||
tz: float,
|
||||
planet_index: int,
|
||||
segment_part: str,
|
||||
ayanamsa: str = "raman",
|
||||
) -> dict[str, Any]:
|
||||
ayanamsa_name = normalize_ayanamsa_name(ayanamsa)
|
||||
daynight = determine_daytime(moment, lat=lat, lon=lon, tz=tz)
|
||||
is_day = bool(daynight["is_daytime"])
|
||||
period = "day" if is_day else "night"
|
||||
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
|
||||
start_year, start_month, start_day, _ = swe.revjul(start_jd + float(tz) / 24.0)
|
||||
weekday = datetime(int(start_year), int(start_month), int(start_day)).weekday()
|
||||
ruler_table = DAY_RULERS if is_day else NIGHT_RULERS
|
||||
part_index = ruler_table[weekday].index(planet_index)
|
||||
fraction = (part_index + (0.5 if segment_part == "middle" else 0.0)) / 8.0
|
||||
segment_jd = start_jd + (end_jd - start_jd) * fraction
|
||||
longitude = _sidereal_ascendant(segment_jd, float(lat), float(lon), ayanamsa_name)
|
||||
return {
|
||||
"longitude": round(longitude, 6),
|
||||
"sign_idx": int(longitude / 30) % 12,
|
||||
"degree_in_sign": round(longitude % 30, 6),
|
||||
"period": period,
|
||||
"weekday": weekday,
|
||||
"planet_index": planet_index,
|
||||
"part_index": part_index,
|
||||
"segment_part": segment_part,
|
||||
"segment_fraction": fraction,
|
||||
"segment_jd_ut": segment_jd,
|
||||
}
|
||||
|
||||
|
||||
def _solar_upagrahas(sun_longitude: float) -> dict[str, dict[str, Any]]:
|
||||
sun = float(sun_longitude) % 360.0
|
||||
longitudes = {
|
||||
"Dhuma": (sun + 133.0 + 20.0 / 60.0) % 360.0,
|
||||
"Vyatipata": (360.0 - ((sun + 133.0 + 20.0 / 60.0) % 360.0)) % 360.0,
|
||||
}
|
||||
longitudes["Parivesha"] = (longitudes["Vyatipata"] + 180.0) % 360.0
|
||||
longitudes["Indrachapa"] = (360.0 - longitudes["Parivesha"]) % 360.0
|
||||
longitudes["Upaketu"] = (sun - 30.0) % 360.0
|
||||
return {
|
||||
name: {
|
||||
"longitude": round(longitude, 6),
|
||||
"sign_idx": int(longitude / 30) % 12,
|
||||
"degree_in_sign": round(longitude % 30, 6),
|
||||
"source": "solar_longitude_formula",
|
||||
}
|
||||
for name, longitude in longitudes.items()
|
||||
}
|
||||
|
||||
|
||||
def calculate_upagrahas(
|
||||
moment: datetime,
|
||||
*,
|
||||
lat: float,
|
||||
lon: float,
|
||||
tz: float,
|
||||
sun_longitude: float,
|
||||
ayanamsa: str = "raman",
|
||||
) -> dict[str, Any]:
|
||||
"""Calculate the D1 Upagraha packet using local Swiss Ephemeris inputs."""
|
||||
ayanamsa_name = normalize_ayanamsa_name(ayanamsa)
|
||||
temporal = {
|
||||
name: _temporal_upagraha(
|
||||
moment,
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
tz=tz,
|
||||
planet_index=planet_index,
|
||||
segment_part=segment_part,
|
||||
ayanamsa=ayanamsa_name,
|
||||
)
|
||||
for name, (planet_index, segment_part) in TEMPORAL_UPAGRAHAS.items()
|
||||
}
|
||||
return {
|
||||
"status": "computed_pending_validation",
|
||||
"raw": {**temporal, **_solar_upagrahas(sun_longitude)},
|
||||
"ayanamsa": ayanamsa_name,
|
||||
"ayanamsa_display": ayanamsa_display_name(ayanamsa_name),
|
||||
"scope": "d1_only",
|
||||
"boundary": (
|
||||
"Local D1 formulas are retained with segment evidence. Public PyJHora observations are "
|
||||
"used only for calibration; D-N Upagraha mapping and external numeric closure remain pending."
|
||||
),
|
||||
}
|
||||
|
||||
+118
-19
@@ -300,6 +300,12 @@ _PLANET_DIGNITY_KNRAO = {
|
||||
# Aquarius (sign 10): 传统主Saturn vs 共主Rahu — PyJHora用stronger_planet动态判定
|
||||
# Scorpio (sign 7): 传统主Mars vs 共主Ketu — PyJHora用stronger_planet动态判定
|
||||
_CHARA_DASHA_CO_LORD_SIGNS = {10, 7} # Aquarius, Scorpio 有共主争议
|
||||
CHARA_DASHA_PROFILES = {
|
||||
"kn_rao",
|
||||
"goel_lagna_start",
|
||||
"goel_gendered",
|
||||
"goel_shastri_method2",
|
||||
}
|
||||
|
||||
|
||||
def _jaimini_planet_dignity_level(planet: str, sign_idx: int) -> int:
|
||||
@@ -439,7 +445,12 @@ def _get_sign_lord_house(longitudes: Dict[str, float], sign_idx: int) -> int:
|
||||
return _get_planet_house(longitudes, lord)
|
||||
|
||||
|
||||
def _chara_dasha_duration_knrao(longitudes: Dict[str, float], sign_idx: int) -> int:
|
||||
def _chara_dasha_duration_profile(
|
||||
longitudes: Dict[str, float],
|
||||
sign_idx: int,
|
||||
*,
|
||||
profile: str = "kn_rao",
|
||||
) -> int:
|
||||
"""
|
||||
KN Rao Chara Dasha 大运时长计算 v6.1.12。
|
||||
|
||||
@@ -453,7 +464,31 @@ def _chara_dasha_duration_knrao(longitudes: Dict[str, float], sign_idx: int) ->
|
||||
若宫主在所在宫位 ⟹ Exalted(+1);Debilitated(-1)
|
||||
Mercury在Virgo/Gemini是own sign非exalted,不+1
|
||||
"""
|
||||
# 动态宫主判定:Aquarius→Saturn/Rahu比较,Scorpio→Mars/Ketu比较
|
||||
if profile not in CHARA_DASHA_PROFILES:
|
||||
raise ValueError(f"unsupported Chara Dasha profile: {profile}")
|
||||
|
||||
if profile == "goel_lagna_start":
|
||||
lord = SIGN_LORDS[SIGNS[sign_idx]]
|
||||
lord_house = _get_planet_house(longitudes, lord)
|
||||
if lord_house == sign_idx:
|
||||
return 12
|
||||
return _count_rasis_forward(sign_idx, lord_house)
|
||||
|
||||
if profile == "goel_gendered":
|
||||
lord = SIGN_LORDS[SIGNS[sign_idx]]
|
||||
lord_house = _get_planet_house(longitudes, lord)
|
||||
if lord_house == sign_idx:
|
||||
return 12
|
||||
if (lord_house - sign_idx) % 12 == 6:
|
||||
return 10
|
||||
if sign_idx % 2 == 0:
|
||||
count = _count_rasis_forward(lord_house, sign_idx)
|
||||
else:
|
||||
count = _count_rasis_backward(lord_house, sign_idx)
|
||||
return max(1, count - 1)
|
||||
|
||||
# KN Rao keeps Scorpio/Aquarius dual-lord separation; start/order rules are
|
||||
# handled by the progression helper.
|
||||
lord = _resolve_chara_dasha_lord(longitudes, sign_idx)
|
||||
|
||||
lord_house = _get_planet_house(longitudes, lord)
|
||||
@@ -469,7 +504,9 @@ def _chara_dasha_duration_knrao(longitudes: Dict[str, float], sign_idx: int) ->
|
||||
if years <= 0:
|
||||
years = 12
|
||||
|
||||
# 尊贵调整(对齐PyJHora house_strengths_of_planets表)
|
||||
# This remains the historical PyJHora-aligned local KN Rao profile. The
|
||||
# source ledger keeps the no-extra-year K.N. Rao textual boundary visible
|
||||
# for later formula reconciliation.
|
||||
dignities = _PLANET_DIGNITY_KNRAO.get(lord, {})
|
||||
if dignities:
|
||||
if lord_house in dignities.get('exalted', set()):
|
||||
@@ -480,7 +517,23 @@ def _chara_dasha_duration_knrao(longitudes: Dict[str, float], sign_idx: int) ->
|
||||
return years
|
||||
|
||||
|
||||
def _chara_progression_knrao(asc_sign_idx: int, longitudes: Dict[str, float]) -> list:
|
||||
def _chara_dasha_duration_knrao(longitudes: Dict[str, float], sign_idx: int) -> int:
|
||||
return _chara_dasha_duration_profile(longitudes, sign_idx, profile="kn_rao")
|
||||
|
||||
|
||||
def _fourth_from_lagna_prakriti(asc_sign_idx: int) -> int:
|
||||
if asc_sign_idx % 2 == 0:
|
||||
return (asc_sign_idx + 3) % 12
|
||||
return (asc_sign_idx - 3) % 12
|
||||
|
||||
|
||||
def _chara_progression_profile(
|
||||
asc_sign_idx: int,
|
||||
longitudes: Dict[str, float],
|
||||
*,
|
||||
profile: str = "kn_rao",
|
||||
gender: str | None = None,
|
||||
) -> list:
|
||||
"""
|
||||
KN Rao Chara Dasha 星座序列生成。
|
||||
|
||||
@@ -489,11 +542,19 @@ def _chara_progression_knrao(asc_sign_idx: int, longitudes: Dict[str, float]) ->
|
||||
2. 检查第9宫:若为偶数脚 → 逆向,否则正向
|
||||
3. 生成12个星座的顺序
|
||||
"""
|
||||
if profile not in CHARA_DASHA_PROFILES:
|
||||
raise ValueError(f"unsupported Chara Dasha profile: {profile}")
|
||||
start_idx = asc_sign_idx
|
||||
if profile == "goel_gendered" and (gender or "").lower().startswith("f"):
|
||||
start_idx = _fourth_from_lagna_prakriti(asc_sign_idx)
|
||||
ninth_idx = (asc_sign_idx + 8) % 12
|
||||
if _sign_is_even_footed(ninth_idx):
|
||||
return [(asc_sign_idx + 12 - i) % 12 for i in range(12)]
|
||||
else:
|
||||
return [(asc_sign_idx + i) % 12 for i in range(12)]
|
||||
return [(start_idx + 12 - i) % 12 for i in range(12)]
|
||||
return [(start_idx + i) % 12 for i in range(12)]
|
||||
|
||||
|
||||
def _chara_progression_knrao(asc_sign_idx: int, longitudes: Dict[str, float]) -> list:
|
||||
return _chara_progression_profile(asc_sign_idx, longitudes, profile="kn_rao")
|
||||
|
||||
|
||||
def _jd_to_date_tuple(jd: float):
|
||||
@@ -522,7 +583,9 @@ def _jd_to_date_tuple(jd: float):
|
||||
def calc_chara_dasha(asc_sign_idx: int,
|
||||
planet_longitudes: Dict[str, float],
|
||||
birth_year: int, birth_month: int,
|
||||
birth_day: int = 1) -> Dict:
|
||||
birth_day: int = 1,
|
||||
profile: str = "kn_rao",
|
||||
gender: str | None = None) -> Dict:
|
||||
"""
|
||||
Chara Dasha计算(v6.1.11重写:KN Rao Method)
|
||||
|
||||
@@ -534,13 +597,21 @@ def calc_chara_dasha(asc_sign_idx: int,
|
||||
- 时长:基于宫主所在宫位而非行星计数
|
||||
- 尊贵:Exalted +1年 / Debilitated -1年
|
||||
"""
|
||||
progression = _chara_progression_knrao(asc_sign_idx, planet_longitudes)
|
||||
progression = _chara_progression_profile(
|
||||
asc_sign_idx,
|
||||
planet_longitudes,
|
||||
profile=profile,
|
||||
gender=gender,
|
||||
)
|
||||
|
||||
dasha_sequence = []
|
||||
for i, sign_idx in enumerate(progression):
|
||||
sign_name = SIGNS[sign_idx]
|
||||
lord = _resolve_chara_dasha_lord(planet_longitudes, sign_idx)
|
||||
duration = _chara_dasha_duration_knrao(planet_longitudes, sign_idx)
|
||||
if profile == "goel_lagna_start":
|
||||
lord = SIGN_LORDS[sign_name]
|
||||
else:
|
||||
lord = _resolve_chara_dasha_lord(planet_longitudes, sign_idx)
|
||||
duration = _chara_dasha_duration_profile(planet_longitudes, sign_idx, profile=profile)
|
||||
|
||||
# 宮主所在宫位
|
||||
lord_house = _get_planet_house(planet_longitudes, lord)
|
||||
@@ -549,7 +620,9 @@ def calc_chara_dasha(asc_sign_idx: int,
|
||||
# 尊贵状态
|
||||
dignities = _PLANET_DIGNITY_KNRAO.get(lord, {})
|
||||
dignity_status = 'none'
|
||||
if dignities:
|
||||
if profile == "goel_lagna_start":
|
||||
dignity_status = 'not_applied'
|
||||
elif dignities:
|
||||
exalted_set = dignities.get('exalted', set())
|
||||
debil_set = dignities.get('debilitated', set())
|
||||
if lord_house in exalted_set:
|
||||
@@ -571,7 +644,10 @@ def calc_chara_dasha(asc_sign_idx: int,
|
||||
total_years = sum(d['duration_years'] for d in dasha_sequence)
|
||||
|
||||
return {
|
||||
'method': 'Chara Dasha (KN Rao Method, v6.1.11, PyJHora-aligned)',
|
||||
'method': f'Chara Dasha ({profile})',
|
||||
'variant_profile': profile,
|
||||
'variant_profile_status': 'profiled',
|
||||
'gender': gender,
|
||||
'ascendant': SIGNS[asc_sign_idx],
|
||||
'ascendant_idx': asc_sign_idx,
|
||||
'progression_source': '9th_house_direction',
|
||||
@@ -584,22 +660,45 @@ def calc_chara_dasha(asc_sign_idx: int,
|
||||
def calc_chara_dasha_with_antardasha(asc_sign_idx: int,
|
||||
planet_longitudes: Dict[str, float],
|
||||
birth_year: int, birth_month: int,
|
||||
birth_day: int = 1) -> Dict:
|
||||
birth_day: int = 1,
|
||||
profile: str = "kn_rao",
|
||||
gender: str | None = None) -> Dict:
|
||||
"""
|
||||
Chara Dasha 完整3层计算(MD → AD → PD)
|
||||
|
||||
使用KN Rao方法(v6.1.11重写)。
|
||||
Antardasha:等分法(parent/12),序列为Maha序列偏移1位。
|
||||
"""
|
||||
base = calc_chara_dasha(asc_sign_idx, planet_longitudes, birth_year, birth_month, birth_day)
|
||||
progression = _chara_progression_knrao(asc_sign_idx, planet_longitudes)
|
||||
|
||||
# Antardasha序列:Mahadasha序列偏移1(PyJHora method=2)
|
||||
antar_sequence = progression[1:] + progression[:1]
|
||||
base = calc_chara_dasha(
|
||||
asc_sign_idx,
|
||||
planet_longitudes,
|
||||
birth_year,
|
||||
birth_month,
|
||||
birth_day,
|
||||
profile=profile,
|
||||
gender=gender,
|
||||
)
|
||||
progression = _chara_progression_profile(
|
||||
asc_sign_idx,
|
||||
planet_longitudes,
|
||||
profile=profile,
|
||||
gender=gender,
|
||||
)
|
||||
|
||||
for md in base['dasha_sequence']:
|
||||
md_sign_idx = md['sign_idx']
|
||||
md_duration_years = md['duration_years']
|
||||
if profile == "goel_shastri_method2":
|
||||
# Goel's Method 2 worked example gives the first sub-period to the
|
||||
# mahadasha sign and then follows that sign's own direct/indirect order.
|
||||
ninth_from_md = (md_sign_idx + 8) % 12
|
||||
if _sign_is_even_footed(ninth_from_md):
|
||||
antar_sequence = [(md_sign_idx + 12 - i) % 12 for i in range(12)]
|
||||
else:
|
||||
antar_sequence = [(md_sign_idx + i) % 12 for i in range(12)]
|
||||
else:
|
||||
# Antardasha序列:Mahadasha序列偏移1(PyJHora method=2)
|
||||
antar_sequence = progression[1:] + progression[:1]
|
||||
|
||||
antardasha_list = []
|
||||
for j, ad_sign_idx in enumerate(antar_sequence):
|
||||
|
||||
+11094
-71
File diff suppressed because it is too large
Load Diff
+328
-7
@@ -10,8 +10,10 @@ KP (Krishnamurti Paddhati) 占星系统模块
|
||||
3. House Significator ABCD体系
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Tuple, Optional
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Tuple, Optional
|
||||
|
||||
SIGNS = ['Aries', 'Taurus', 'Gemini', 'Cancer', 'Leo', 'Virgo',
|
||||
'Libra', 'Scorpio', 'Sagittarius', 'Capricorn', 'Aquarius', 'Pisces']
|
||||
@@ -38,6 +40,171 @@ VIMSHOTTARI_YEARS = dict(zip(KP_LORDS, VIMSHOTTARI_DURATION))
|
||||
|
||||
NAKSHATRA_SPAN = 360.0 / 27.0 # 13.333... degrees
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _load_json_artifact(relative_path: str) -> Dict[str, Any]:
|
||||
path = ROOT / relative_path
|
||||
with open(path, encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
data.setdefault('artifact_path', relative_path)
|
||||
return data
|
||||
|
||||
|
||||
def kp_maturity_profile() -> Dict[str, Any]:
|
||||
"""Return the current KP maturity boundary for reports and API consumers.
|
||||
|
||||
This intentionally reuses pinned oracle/status packets instead of inventing
|
||||
a second truth policy inside runtime code. It is a display/report guard:
|
||||
KP layers can be shown, but prediction truth stays blocked until the status
|
||||
packets say the numeric oracle and independent holdout gates are closed.
|
||||
"""
|
||||
event = _load_json_artifact('references/oracle/kp_exact_cusp_mainline_status_2026_08_22.json')
|
||||
gate = _load_json_artifact('references/oracle/kp_exact_cusp_closure_dashboard_2026_08_22.json')
|
||||
replay = _load_json_artifact('references/oracle/kp_real_event_replay_gate_2026_07_30.json')
|
||||
cusp = _load_json_artifact('references/oracle/kp_12_cusp_numeric_oracle_readiness_2026_07_23.json')
|
||||
table = _load_json_artifact('references/oracle/kp_external_table_hash_manifest_2026_07_20.json')
|
||||
workflow_gate = _load_json_artifact('references/oracle/kp_significator_workflow_gate_2026_07_23.json')
|
||||
|
||||
runtime_evidence_layers = workflow_gate.get('runtime_evidence_layers') or []
|
||||
remaining_hard_reasons = ((event.get('promotion_gate') or {}).get('remaining_hard_reasons')) or []
|
||||
holdout_counts = replay.get('current_holdout_counts') or {}
|
||||
required_counts = replay.get('required_holdout_counts') or {}
|
||||
closed = (
|
||||
event.get('truth_matrix_allowed') is True
|
||||
and event.get('timing_truth_promoted') is True
|
||||
and replay.get('timing_truth_promoted') is True
|
||||
and (holdout_counts.get('frozen_positive_count') or 0) >= (required_counts.get('minimum_frozen_positive') or 20)
|
||||
and (holdout_counts.get('frozen_negative_count') or 0) >= (required_counts.get('minimum_frozen_negative') or 80)
|
||||
)
|
||||
|
||||
if closed:
|
||||
claim_status = 'timing_truth_closed'
|
||||
display_policy = 'verified_prediction_allowed_with_evidence'
|
||||
else:
|
||||
claim_status = 'observation_only_truth_blocked'
|
||||
display_policy = 'show_kp_layers_as_research_evidence_only_do_not_claim_precise_timing'
|
||||
|
||||
blockers = []
|
||||
blockers.extend([row.get('blocker') for row in (event.get('remaining_blockers') or []) if isinstance(row, dict)])
|
||||
blockers.extend(replay.get('maturity_gap') or [])
|
||||
blockers.extend(cusp.get('remaining_blockers') or [])
|
||||
blockers = list(dict.fromkeys(str(item) for item in blockers if item))
|
||||
|
||||
return {
|
||||
'scope': 'kp_maturity_profile',
|
||||
'claim_status': claim_status,
|
||||
'display_policy': display_policy,
|
||||
'timing_truth_promoted': closed,
|
||||
'truth_matrix_allowed': closed,
|
||||
'production_tuning_allowed': closed,
|
||||
'source_artifacts': {
|
||||
'event_closure_status': event['artifact_path'],
|
||||
'event_closure_dashboard': gate['artifact_path'],
|
||||
'real_event_replay_gate': replay['artifact_path'],
|
||||
'cusp_numeric_oracle_readiness': cusp['artifact_path'],
|
||||
'external_table_hash_manifest': table['artifact_path'],
|
||||
'significator_workflow_gate': workflow_gate['artifact_path'],
|
||||
},
|
||||
'runtime_evidence_layers': runtime_evidence_layers,
|
||||
'runtime_evidence_layer_count': len(runtime_evidence_layers),
|
||||
'remaining_hard_reasons': remaining_hard_reasons,
|
||||
'remaining_hard_reason_count': len(remaining_hard_reasons),
|
||||
'selected_lane': ((event.get('selector') or {}).get('selected_lane')),
|
||||
'fallback_lane': ((event.get('selector') or {}).get('fallback_lane')),
|
||||
'promotion_gate_passed': bool((event.get('promotion_gate') or {}).get('gate_passed', False)),
|
||||
'closed_numeric_assets': {
|
||||
'kp_sub_lord_fixture_hash_fixed': table.get('status') == 'fixed_hash' and table.get('row_count') == 249,
|
||||
'kp_sub_lord_midpoint_all_249_guarded': True,
|
||||
'kp_sub_lord_249_segment_parity_closed': table.get('status') == 'fixed_hash' and table.get('row_count') == 249,
|
||||
'public_12_cusp_packet_ready': bool(cusp.get('ready_evidence')),
|
||||
},
|
||||
'closed_oracle_parity_assets': {
|
||||
'kp_sub_lord_249_segment_parity': {
|
||||
'status': 'closed',
|
||||
'scope': 'structure_only_not_event_timing',
|
||||
'source_artifact': table['artifact_path'],
|
||||
'test_guard': 'tests/test_kp_system.py::test_kp_sublord_matches_vedicastro_csv_all_249_segments',
|
||||
'boundary': 'This closes the 249 SubLord segmentation/table parity only; it does not close cusp, ruling-planet, significator workflow, DBA, real-event, or timing-outcome truth.',
|
||||
},
|
||||
},
|
||||
'holdout_counts': holdout_counts,
|
||||
'required_holdout_counts': required_counts,
|
||||
'remaining_blockers': blockers,
|
||||
'claim_boundary': (
|
||||
'KP star/sub/sub-sub/significator/DBA layers may be displayed in the personal report, '
|
||||
'but exact event timing remains blocked until numeric oracle settings, ruling planets, '
|
||||
'and independent 20 positive / 80 negative holdout replay are closed.'
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def build_kp_western_support_surface(
|
||||
western_support: Optional[Dict[str, Any]] = None,
|
||||
*,
|
||||
maturity_profile: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Normalize Western support for KP report-facing surfaces only.
|
||||
|
||||
This packages support/convergence/negative-evidence summaries without
|
||||
changing the underlying KP maturity boundary.
|
||||
"""
|
||||
maturity = maturity_profile or kp_maturity_profile()
|
||||
source = western_support if isinstance(western_support, dict) else {}
|
||||
convergence_source = source.get('convergence') if isinstance(source.get('convergence'), dict) else {}
|
||||
negative_source = source.get('negative_evidence') if isinstance(source.get('negative_evidence'), dict) else {}
|
||||
|
||||
shared_signal_count = convergence_source.get('shared_signal_count')
|
||||
shared_signal_count = shared_signal_count if isinstance(shared_signal_count, int) else 0
|
||||
conflict_count = convergence_source.get('conflict_count')
|
||||
conflict_count = conflict_count if isinstance(conflict_count, int) else 0
|
||||
missing_layers = negative_source.get('missing_layers')
|
||||
missing_layers = [str(item) for item in missing_layers] if isinstance(missing_layers, list) else []
|
||||
rejected_windows = negative_source.get('rejected_windows')
|
||||
rejected_windows = [str(item) for item in rejected_windows] if isinstance(rejected_windows, list) else []
|
||||
|
||||
convergence_status = str(convergence_source.get('status') or ('not_provided' if not source else 'partial'))
|
||||
negative_status = str(negative_source.get('status') or ('not_provided' if not source else 'partial'))
|
||||
if not source:
|
||||
status = 'not_provided'
|
||||
elif 'blocked' in {convergence_status, negative_status}:
|
||||
status = 'blocked'
|
||||
elif 'partial' in {convergence_status, negative_status}:
|
||||
status = 'partial'
|
||||
elif convergence_status == negative_status == 'used':
|
||||
status = 'used'
|
||||
else:
|
||||
status = 'partial'
|
||||
|
||||
convergence_summary = convergence_source.get('summary')
|
||||
if not convergence_summary:
|
||||
convergence_summary = f'{shared_signal_count} shared signals; {conflict_count} conflicts'
|
||||
negative_summary = negative_source.get('summary')
|
||||
if not negative_summary:
|
||||
negative_summary = f'{len(missing_layers)} missing layers; {len(rejected_windows)} rejected windows'
|
||||
|
||||
return {
|
||||
'status': status,
|
||||
'convergence': {
|
||||
'status': convergence_status,
|
||||
'shared_signal_count': shared_signal_count,
|
||||
'conflict_count': conflict_count,
|
||||
'summary': str(convergence_summary),
|
||||
},
|
||||
'negative_evidence': {
|
||||
'status': negative_status,
|
||||
'missing_layers': missing_layers,
|
||||
'rejected_windows': rejected_windows,
|
||||
'summary': str(negative_summary),
|
||||
},
|
||||
'claim_boundary': (
|
||||
'Western support remains a KP support layer only; it cannot upgrade blocked KP timing truth '
|
||||
'or replace Jyotish-first adjudication.'
|
||||
),
|
||||
'maturity_claim_status': maturity.get('claim_status'),
|
||||
'truth_matrix_allowed': maturity.get('truth_matrix_allowed') is True,
|
||||
}
|
||||
|
||||
|
||||
def get_kp_lords(degree: float) -> Dict:
|
||||
"""
|
||||
@@ -200,13 +367,18 @@ def get_house_significators(planet_positions: Dict, houses: List[Dict]) -> Dict:
|
||||
return results
|
||||
|
||||
|
||||
def calc_kp_analysis(planet_positions: Dict, asc_sign: str = 'Aries') -> Dict:
|
||||
def calc_kp_analysis(
|
||||
planet_positions: Dict,
|
||||
asc_sign: str = 'Aries',
|
||||
house_cusps: Optional[List[float]] = None,
|
||||
) -> Dict:
|
||||
"""
|
||||
完整KP分析(基于 diliprk/VedicAstro MIT 算法)。
|
||||
|
||||
Args:
|
||||
planet_positions: 行星位置 {planet: {'sign': str, 'degree': float, 'house': int}}
|
||||
asc_sign: 上升星座名称
|
||||
asc_sign: 上升星座名称(无显式宫头时用于 whole-sign 代理)
|
||||
house_cusps: 可选的 12 个实际宫头黄经。提供时优先用于 KP 宫头与显著星。
|
||||
|
||||
Returns:
|
||||
完整KP分析结果
|
||||
@@ -232,12 +404,20 @@ def calc_kp_analysis(planet_positions: Dict, asc_sign: str = 'Aries') -> Dict:
|
||||
'kp_lords': kp_lords,
|
||||
}
|
||||
|
||||
# 2. 构建宫位信息(含KP lords)
|
||||
explicit_cusps = house_cusps is not None
|
||||
if explicit_cusps and len(house_cusps) != 12:
|
||||
raise ValueError('house_cusps must contain exactly 12 longitudes')
|
||||
|
||||
# 2. 构建宫位信息(含 KP lords)。无实际宫头时保留历史 whole-sign 中点代理。
|
||||
houses = []
|
||||
for house_num in range(1, 13):
|
||||
sign_idx = (asc_sign_idx + house_num - 1) % 12
|
||||
if explicit_cusps:
|
||||
house_center_degree = float(house_cusps[house_num - 1]) % 360.0
|
||||
sign_idx = int(house_center_degree // 30) % 12
|
||||
else:
|
||||
sign_idx = (asc_sign_idx + house_num - 1) % 12
|
||||
house_center_degree = sign_idx * 30 + 15.0
|
||||
sign_name = SIGNS[sign_idx]
|
||||
house_center_degree = sign_idx * 30 + 15.0 # 宫位中点
|
||||
kp_lords = get_kp_lords(house_center_degree)
|
||||
|
||||
houses.append({
|
||||
@@ -245,6 +425,7 @@ def calc_kp_analysis(planet_positions: Dict, asc_sign: str = 'Aries') -> Dict:
|
||||
'sign': sign_name,
|
||||
'rasi_lord': SIGN_LORDS.get(sign_name, ''),
|
||||
'kp_lords': kp_lords,
|
||||
'cusp_longitude': round(house_center_degree, 6),
|
||||
})
|
||||
|
||||
# 3. 计算significators
|
||||
@@ -255,13 +436,153 @@ def calc_kp_analysis(planet_positions: Dict, asc_sign: str = 'Aries') -> Dict:
|
||||
'method': 'KP (Krishnamurti Paddhati) 系统',
|
||||
'version': '1.0',
|
||||
'source': 'diliprk/VedicAstro MIT License',
|
||||
'maturity_profile': kp_maturity_profile(),
|
||||
'house_basis': 'explicit_cusps' if explicit_cusps else 'whole_sign_proxy',
|
||||
'planets': {pname: {'kp_lords': data['kp_lords'], 'significators': planet_sig.get(pname, {})}
|
||||
for pname, data in kp_planets.items()},
|
||||
'houses': {h['house']: {'sign': h['sign'], 'kp_lords': h['kp_lords'], 'significators': house_sig.get(h['house'], {})}
|
||||
'houses': {h['house']: {'sign': h['sign'], 'cusp_longitude': h['cusp_longitude'], 'kp_lords': h['kp_lords'], 'significators': house_sig.get(h['house'], {})}
|
||||
for h in houses},
|
||||
}
|
||||
|
||||
|
||||
def build_kp_report_pack(
|
||||
kp_analysis: Dict[str, Any],
|
||||
timeline: Optional[Dict[str, Any]] = None,
|
||||
*,
|
||||
profile: Optional[Dict[str, Any]] = None,
|
||||
western_support: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Build a report-ready KP evidence pack without upgrading prediction truth.
|
||||
|
||||
This is a thin packaging layer for personal reports. It does not recompute
|
||||
astrology, does not adjudicate event truth, and keeps the KP maturity profile
|
||||
visible so downstream PL9+ renderers can show KP layers without presenting
|
||||
blocked timing evidence as verified prediction.
|
||||
"""
|
||||
maturity = profile or kp_analysis.get('maturity_profile') or kp_maturity_profile()
|
||||
planets = kp_analysis.get('planets') or {}
|
||||
houses = kp_analysis.get('houses') or {}
|
||||
periods = (timeline or {}).get('periods') or []
|
||||
runtime_layers = maturity.get('runtime_evidence_layers') or []
|
||||
runtime_layer_names = [str(layer.get('layer')) for layer in runtime_layers if isinstance(layer, dict) and layer.get('layer')]
|
||||
closure_gap_matrix = maturity.get('closure_gap_matrix') or [
|
||||
{'gap_id': reason, 'truth_upgrade_allowed': False}
|
||||
for reason in (maturity.get('remaining_hard_reasons') or [])
|
||||
]
|
||||
closure_gap_ids = [str(row.get('gap_id')) for row in closure_gap_matrix if isinstance(row, dict) and row.get('gap_id')]
|
||||
claim_boundary = maturity.get('claim_boundary') or 'KP timing truth remains blocked.'
|
||||
western_support_surface = build_kp_western_support_surface(
|
||||
western_support,
|
||||
maturity_profile=maturity,
|
||||
)
|
||||
status = 'verified_prediction_allowed' if maturity.get('truth_matrix_allowed') is True else 'observation_only'
|
||||
must_not_claim = [
|
||||
'kp_precise_event_timing_truth_closed',
|
||||
'kp_real_event_replay_completed',
|
||||
'kp_holdout_20_positive_80_negative_closed',
|
||||
'kp_dba_periods_are_verified_predictions',
|
||||
]
|
||||
executive = (
|
||||
'KP layers are report-ready as research evidence, but precise event timing remains blocked.'
|
||||
if status == 'observation_only'
|
||||
else 'KP timing evidence is marked verified by the maturity profile.'
|
||||
)
|
||||
closed_parity_assets = maturity.get('closed_oracle_parity_assets') or {}
|
||||
markdown = '\n'.join([
|
||||
'## KP Evidence Pack',
|
||||
f"- status: `{status}`",
|
||||
f"- claim_status: `{maturity.get('claim_status')}`",
|
||||
f"- truth_matrix_allowed: `{maturity.get('truth_matrix_allowed')}`",
|
||||
f"- closed_oracle_parity_assets: `{', '.join(closed_parity_assets.keys())}`",
|
||||
f"- planet_count: `{len(planets)}`",
|
||||
f"- house_count: `{len(houses)}`",
|
||||
f"- dba_period_count: `{len(periods)}`",
|
||||
f"- runtime_evidence_layer_count: `{len(runtime_layers)}`",
|
||||
f"- runtime_evidence_layers: `{', '.join(runtime_layer_names)}`",
|
||||
f"- closure_gap_count: `{len(closure_gap_matrix)}`",
|
||||
f"- closure_gap_ids: `{', '.join(closure_gap_ids)}`",
|
||||
f"- western_support_status: `{western_support_surface.get('status')}`",
|
||||
f"- western_support_convergence_status: `{western_support_surface.get('convergence', {}).get('status')}`",
|
||||
f"- western_support_negative_evidence_status: `{western_support_surface.get('negative_evidence', {}).get('status')}`",
|
||||
f"- claim_boundary: {claim_boundary}",
|
||||
])
|
||||
return {
|
||||
'schema': 'jyotish.kp_report_pack.v1',
|
||||
'status': status,
|
||||
'summary': {
|
||||
'planet_count': len(planets),
|
||||
'house_count': len(houses),
|
||||
'dba_period_count': len(periods),
|
||||
'runtime_evidence_layer_count': len(runtime_layers),
|
||||
'closure_gap_count': len(closure_gap_matrix),
|
||||
'claim_status': maturity.get('claim_status'),
|
||||
'truth_matrix_allowed': maturity.get('truth_matrix_allowed') is True,
|
||||
'closed_oracle_parity_asset_count': len(closed_parity_assets),
|
||||
'western_support': {
|
||||
'status': western_support_surface.get('status'),
|
||||
'convergence_status': western_support_surface.get('convergence', {}).get('status'),
|
||||
'negative_evidence_status': western_support_surface.get('negative_evidence', {}).get('status'),
|
||||
},
|
||||
},
|
||||
'kp_analysis': kp_analysis,
|
||||
'kp_dba_timeline': timeline or {},
|
||||
'maturity_profile': maturity,
|
||||
'report_sections': {
|
||||
'executive_summary': [executive],
|
||||
'thematic_narrative': [
|
||||
{
|
||||
'domain': 'kp_research_evidence',
|
||||
'paragraph': (
|
||||
'KP star, sub-lord, significator, and DBA layers can enrich the personal report as '
|
||||
'auditable research evidence. The report must keep the current blocked timing boundary visible.'
|
||||
),
|
||||
'evidence_label': status,
|
||||
'must_not_claim': list(must_not_claim),
|
||||
}
|
||||
],
|
||||
'evidence_appendix': [
|
||||
{
|
||||
'segment_id': 'kp_maturity_profile',
|
||||
'status': maturity.get('claim_status'),
|
||||
'source_artifacts': maturity.get('source_artifacts') or {},
|
||||
'runtime_evidence_layers': runtime_layers,
|
||||
'closed_oracle_parity_assets': closed_parity_assets,
|
||||
'closure_gap_matrix': closure_gap_matrix,
|
||||
'remaining_blockers': maturity.get('remaining_blockers') or [],
|
||||
'western_support': western_support_surface,
|
||||
'claim_boundary': claim_boundary,
|
||||
}
|
||||
],
|
||||
'pdf_sections': [executive, claim_boundary],
|
||||
},
|
||||
'exports': {
|
||||
'markdown': markdown,
|
||||
'ai_evidence_bundle': {
|
||||
'schema': 'jyotish.kp_report_pack.ai_evidence.v1',
|
||||
'contains_private_pl9_text': False,
|
||||
'maturity_profile': maturity,
|
||||
'runtime_evidence_layers': runtime_layers,
|
||||
'closed_oracle_parity_assets': closed_parity_assets,
|
||||
'closure_gap_matrix': closure_gap_matrix,
|
||||
'western_support': western_support_surface,
|
||||
'summary': {
|
||||
'planet_count': len(planets),
|
||||
'house_count': len(houses),
|
||||
'dba_period_count': len(periods),
|
||||
'runtime_evidence_layer_count': len(runtime_layers),
|
||||
'closure_gap_count': len(closure_gap_matrix),
|
||||
},
|
||||
},
|
||||
},
|
||||
'audit': {
|
||||
'status': status,
|
||||
'must_not_claim': must_not_claim,
|
||||
'claim_boundary': claim_boundary,
|
||||
'normalization_boundary': 'packaging_only_no_astrological_recalculation',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _kp_next_lords(start_lord: str) -> List[str]:
|
||||
idx = KP_LORDS.index(start_lord)
|
||||
return KP_LORDS[idx:] + KP_LORDS[:idx]
|
||||
|
||||
+1605
-13
File diff suppressed because it is too large
Load Diff
@@ -38,6 +38,104 @@ PLANET_SIGN_INDEX = {
|
||||
'Rahu': 10, 'Ketu': 7, # traditional assignments
|
||||
}
|
||||
|
||||
_NARAYANA_ODD_FOOTED_SIGNS = {0, 1, 2, 6, 7, 8}
|
||||
_NARAYANA_DURATION_PROFILES = {
|
||||
"legacy_forward_v0",
|
||||
"jaimini_odd_footed_v1",
|
||||
"jaimini_odd_footed_dignity_v2",
|
||||
}
|
||||
_NARAYANA_GENERAL_SEQUENCE = {
|
||||
0: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
|
||||
1: [1, 8, 3, 10, 5, 0, 7, 2, 9, 4, 11, 6],
|
||||
2: [2, 10, 6, 5, 1, 9, 8, 4, 0, 11, 7, 3],
|
||||
3: [3, 2, 1, 0, 11, 10, 9, 8, 7, 6, 5, 4],
|
||||
4: [4, 9, 2, 7, 0, 5, 10, 3, 8, 1, 6, 11],
|
||||
5: [5, 9, 1, 2, 6, 10, 11, 3, 7, 8, 0, 4],
|
||||
6: [6, 7, 8, 9, 10, 11, 0, 1, 2, 3, 4, 5],
|
||||
7: [7, 2, 9, 4, 11, 6, 1, 8, 3, 10, 5, 0],
|
||||
8: [8, 4, 0, 11, 7, 3, 2, 10, 6, 5, 1, 9],
|
||||
9: [9, 8, 7, 6, 5, 4, 3, 2, 1, 0, 11, 10],
|
||||
10: [10, 3, 8, 1, 6, 11, 4, 9, 2, 7, 0, 5],
|
||||
11: [11, 3, 7, 0, 4, 8, 1, 5, 9, 2, 6, 10],
|
||||
}
|
||||
_NARAYANA_RATH_GENERAL_SEQUENCE = {
|
||||
0: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
|
||||
1: [1, 8, 3, 10, 5, 0, 7, 2, 9, 4, 11, 6],
|
||||
2: [2, 10, 6, 5, 1, 9, 8, 4, 0, 11, 7, 3],
|
||||
3: [3, 2, 1, 0, 11, 10, 9, 8, 7, 6, 5, 4],
|
||||
4: [4, 9, 2, 7, 0, 5, 10, 3, 8, 1, 6, 11],
|
||||
5: [5, 9, 1, 2, 6, 10, 11, 3, 7, 8, 0, 4],
|
||||
6: [6, 7, 8, 9, 10, 11, 0, 1, 2, 3, 4, 5],
|
||||
7: [7, 2, 9, 4, 11, 6, 1, 8, 3, 10, 5, 0],
|
||||
8: [8, 4, 0, 11, 7, 3, 2, 10, 6, 5, 1, 9],
|
||||
9: [9, 8, 7, 6, 5, 4, 3, 2, 1, 0, 11, 10],
|
||||
10: [10, 3, 8, 1, 6, 11, 4, 9, 2, 7, 0, 5],
|
||||
11: [11, 3, 7, 8, 0, 4, 5, 9, 1, 2, 6, 10],
|
||||
}
|
||||
_NARAYANA_SEQUENCE_PROFILES = {
|
||||
"legacy_zodiacal_v0",
|
||||
"jaimini_general_order_v1",
|
||||
"rath_general_table10_v1",
|
||||
"rath_saturn_table11_v1",
|
||||
}
|
||||
_NARAYANA_SEED_PROFILES = {"legacy_lagna_v0", "jaimini_lagna_seventh_strength_v1"}
|
||||
_NARAYANA_DUAL_LORD_PROFILES = {"legacy_primary_lord_v0", "jaimini_dual_lord_source_v1"}
|
||||
_NARAYANA_ANTARDASHA_PROFILES = {
|
||||
"legacy_weighted_v0",
|
||||
"parashara_equal_v1",
|
||||
"pl9_observed_stronger_sign_v1",
|
||||
"pl9_observed_direction_v1",
|
||||
"pl9_observed_direction_tie_break_v1",
|
||||
}
|
||||
_NARAYANA_AD_ORDER_TABLE13 = {
|
||||
0: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
|
||||
1: [1, 0, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2],
|
||||
2: [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 0, 1],
|
||||
3: [3, 2, 1, 0, 11, 10, 9, 8, 7, 6, 5, 4],
|
||||
4: [4, 5, 6, 7, 8, 9, 10, 11, 0, 1, 2, 3],
|
||||
5: [5, 4, 3, 2, 1, 0, 11, 10, 9, 8, 7, 6],
|
||||
6: [6, 7, 8, 9, 10, 11, 0, 1, 2, 3, 4, 5],
|
||||
7: [7, 6, 5, 4, 3, 2, 1, 0, 11, 10, 9, 8],
|
||||
8: [8, 9, 10, 11, 0, 1, 2, 3, 4, 5, 6, 7],
|
||||
9: [9, 8, 7, 6, 5, 4, 3, 2, 1, 0, 11, 10],
|
||||
10: [10, 11, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
|
||||
11: [11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0],
|
||||
}
|
||||
_NARAYANA_AD_KETU_TABLE14 = {
|
||||
0: [0, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1],
|
||||
1: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 0],
|
||||
2: [2, 1, 0, 11, 10, 9, 8, 7, 6, 5, 4, 3],
|
||||
3: [3, 4, 5, 6, 7, 8, 9, 10, 11, 0, 1, 2],
|
||||
4: [4, 3, 2, 1, 0, 11, 10, 9, 8, 7, 6, 5],
|
||||
5: [5, 6, 7, 8, 9, 10, 11, 0, 1, 2, 3, 4],
|
||||
6: [6, 5, 4, 3, 2, 1, 0, 11, 10, 9, 8, 7],
|
||||
# Source/OCR line repeats Cancer/Gemini at the tail. Keep the normalized
|
||||
# reversal rule here; the ledger records the printed anomaly separately.
|
||||
7: [7, 8, 9, 10, 11, 0, 1, 2, 3, 4, 5, 6],
|
||||
8: [8, 7, 6, 5, 4, 3, 2, 1, 0, 11, 10, 9],
|
||||
9: [9, 10, 11, 0, 1, 2, 3, 4, 5, 6, 7, 8],
|
||||
10: [10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0, 11],
|
||||
11: [11, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
|
||||
}
|
||||
_NARAYANA_AD_ORDER_PROFILES = {
|
||||
"rath_table13_v1",
|
||||
"rath_ketu_table14_v1",
|
||||
"rath_saturn_table11_v1",
|
||||
}
|
||||
_MOVABLE_SIGNS = {0, 3, 6, 9}
|
||||
_FIXED_SIGNS = {1, 4, 7, 10}
|
||||
_NARAYANA_DIGNITY = {
|
||||
"Sun": {"exalted": {0}, "debilitated": {6}},
|
||||
"Moon": {"exalted": {1}, "debilitated": {7}},
|
||||
"Mars": {"exalted": {9}, "debilitated": {3}},
|
||||
"Mercury": {"exalted": {5}, "debilitated": {11}},
|
||||
"Jupiter": {"exalted": {3}, "debilitated": {9}},
|
||||
"Venus": {"exalted": {11}, "debilitated": {5}},
|
||||
"Saturn": {"exalted": {6}, "debilitated": {0}},
|
||||
}
|
||||
_NARAYANA_NODE_MOOLATRIKONA = {"Rahu": {5}, "Ketu": {7}}
|
||||
_NARAYANA_NODE_OWN_SIGNS = {"Rahu": {10}, "Ketu": {7}}
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 核心计算
|
||||
@@ -60,6 +158,277 @@ def _count_signs_forward(src_idx: int, dest_idx: int) -> int:
|
||||
return count # 已含起点
|
||||
|
||||
|
||||
def build_narayana_sign_sequence(
|
||||
seed_sign_idx: int,
|
||||
*,
|
||||
profile: str = "legacy_zodiacal_v0",
|
||||
) -> List[int]:
|
||||
"""Return a first-cycle sign order for an explicit Narayana sequence profile."""
|
||||
if profile not in _NARAYANA_SEQUENCE_PROFILES:
|
||||
raise ValueError(f"unsupported Narayana sequence profile: {profile}")
|
||||
if not 0 <= seed_sign_idx < 12:
|
||||
raise ValueError("seed sign index must be in 0..11")
|
||||
if profile == "legacy_zodiacal_v0":
|
||||
return [(seed_sign_idx + offset) % 12 for offset in range(12)]
|
||||
if profile == "rath_general_table10_v1":
|
||||
return list(_NARAYANA_RATH_GENERAL_SEQUENCE[seed_sign_idx])
|
||||
if profile == "rath_saturn_table11_v1":
|
||||
return [(seed_sign_idx + offset) % 12 for offset in range(12)]
|
||||
return list(_NARAYANA_GENERAL_SEQUENCE[seed_sign_idx])
|
||||
|
||||
|
||||
def build_narayana_antardasha_order_sequence(
|
||||
start_sign_idx: int,
|
||||
*,
|
||||
profile: str = "rath_table13_v1",
|
||||
) -> List[int]:
|
||||
"""Return Sanjay Rath source-table Antardasha sign order."""
|
||||
if profile not in _NARAYANA_AD_ORDER_PROFILES:
|
||||
raise ValueError(f"unsupported Narayana Antardasha order profile: {profile}")
|
||||
if not 0 <= start_sign_idx < 12:
|
||||
raise ValueError("start sign index must be in 0..11")
|
||||
if profile == "rath_table13_v1":
|
||||
return list(_NARAYANA_AD_ORDER_TABLE13[start_sign_idx])
|
||||
if profile == "rath_ketu_table14_v1":
|
||||
return list(_NARAYANA_AD_KETU_TABLE14[start_sign_idx])
|
||||
return [(start_sign_idx + offset) % 12 for offset in range(12)]
|
||||
|
||||
|
||||
def build_narayana_pratyantardasha_order_sequence(
|
||||
start_sign_idx: int,
|
||||
*,
|
||||
profile: str = "rath_inherit_ad_table13_v1",
|
||||
) -> List[int]:
|
||||
"""Return source-attested PD order by inheriting the Rath AD rolling rule."""
|
||||
if profile == "rath_inherit_ad_table13_v1":
|
||||
return build_narayana_antardasha_order_sequence(
|
||||
start_sign_idx,
|
||||
profile="rath_table13_v1",
|
||||
)
|
||||
if profile == "rath_inherit_ketu_ad_table14_v1":
|
||||
return build_narayana_antardasha_order_sequence(
|
||||
start_sign_idx,
|
||||
profile="rath_ketu_table14_v1",
|
||||
)
|
||||
raise ValueError(f"unsupported Narayana Pratyantardasha order profile: {profile}")
|
||||
|
||||
|
||||
def subdivide_narayana_equal_order(
|
||||
parent_period: Dict,
|
||||
*,
|
||||
start_sign_idx: int,
|
||||
order_profile: str,
|
||||
level: str,
|
||||
parent_key: str,
|
||||
parent_name: str,
|
||||
) -> List[Dict]:
|
||||
"""Split a Narayana parent period into twelve equal source-profile rows."""
|
||||
if not 0 <= start_sign_idx < 12:
|
||||
raise ValueError("start sign index must be in 0..11")
|
||||
total_years = float(parent_period.get("years", 0))
|
||||
parent_start = float(parent_period.get("start_age", 0))
|
||||
parent_end = float(parent_period.get("end_age", parent_start + total_years))
|
||||
if level == "PD":
|
||||
sequence = build_narayana_pratyantardasha_order_sequence(
|
||||
start_sign_idx,
|
||||
profile=order_profile,
|
||||
)
|
||||
else:
|
||||
sequence = build_narayana_antardasha_order_sequence(
|
||||
start_sign_idx,
|
||||
profile=order_profile,
|
||||
)
|
||||
rows = []
|
||||
for index, sign_idx in enumerate(sequence):
|
||||
start_age = parent_start + total_years * index / 12
|
||||
end_age = parent_end if index == 11 else parent_start + total_years * (index + 1) / 12
|
||||
rows.append({
|
||||
"level": level,
|
||||
"sign": SIGNS[sign_idx],
|
||||
"sign_idx": sign_idx,
|
||||
"lord": SIGN_LORDS[SIGNS[sign_idx]],
|
||||
"years": round(max(0.0, end_age - start_age), 6),
|
||||
"start_age": round(start_age, 6),
|
||||
"end_age": round(end_age, 6),
|
||||
"sequence_index": index,
|
||||
"order_profile": order_profile,
|
||||
"start_sign_source": "explicit_source_profile_start_sign",
|
||||
parent_key: parent_name,
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def _has_narayana_sequence_exception(
|
||||
seed_sign_idx: int,
|
||||
planet_lons: Dict[str, float],
|
||||
) -> bool:
|
||||
return any(
|
||||
_get_planet_sign(planet, planet_lons) == seed_sign_idx
|
||||
for planet in ("Saturn", "Ketu")
|
||||
)
|
||||
|
||||
|
||||
def _rasi_aspects(source_sign_idx: int, target_sign_idx: int) -> bool:
|
||||
"""Return Jaimini Rasi Drishti between two signs."""
|
||||
if source_sign_idx in _MOVABLE_SIGNS:
|
||||
return target_sign_idx in _FIXED_SIGNS and target_sign_idx != (source_sign_idx + 1) % 12
|
||||
if source_sign_idx in _FIXED_SIGNS:
|
||||
return target_sign_idx in _MOVABLE_SIGNS and target_sign_idx != (source_sign_idx - 1) % 12
|
||||
return target_sign_idx in {2, 5, 8, 11} and target_sign_idx != source_sign_idx
|
||||
|
||||
|
||||
def select_narayana_seed_sign(
|
||||
lagna_sign_idx: int,
|
||||
planet_lons: Dict[str, float],
|
||||
) -> Dict:
|
||||
"""Select the stronger of Lagna and seventh using the first two rule levels.
|
||||
|
||||
A tie is deliberately returned as blocked: later classical strength rules are
|
||||
not yet implemented and must not be silently substituted with a preference.
|
||||
"""
|
||||
if not 0 <= lagna_sign_idx < 12:
|
||||
raise ValueError("lagna sign index must be in 0..11")
|
||||
candidate_indices = (lagna_sign_idx, (lagna_sign_idx + 6) % 12)
|
||||
candidates = {}
|
||||
for sign_idx in candidate_indices:
|
||||
lord = SIGN_LORDS[SIGNS[sign_idx]]
|
||||
occupants = sorted(
|
||||
planet
|
||||
for planet in planet_lons
|
||||
if _get_planet_sign(planet, planet_lons) == sign_idx
|
||||
)
|
||||
aspect_factors = []
|
||||
for planet in dict.fromkeys(("Jupiter", "Mercury", lord)):
|
||||
planet_sign_idx = _get_planet_sign(planet, planet_lons)
|
||||
if planet_sign_idx is not None and _rasi_aspects(planet_sign_idx, sign_idx):
|
||||
aspect_factors.append(planet)
|
||||
candidates[sign_idx] = {
|
||||
"sign": SIGNS[sign_idx],
|
||||
"lord": lord,
|
||||
"occupants": occupants,
|
||||
"occupant_count": len(occupants),
|
||||
"aspect_factors": aspect_factors,
|
||||
"aspect_factor_count": len(aspect_factors),
|
||||
}
|
||||
|
||||
lagna_candidate = candidates[lagna_sign_idx]
|
||||
seventh_sign_idx = (lagna_sign_idx + 6) % 12
|
||||
seventh_candidate = candidates[seventh_sign_idx]
|
||||
lagna_score = (lagna_candidate["occupant_count"], lagna_candidate["aspect_factor_count"])
|
||||
seventh_score = (seventh_candidate["occupant_count"], seventh_candidate["aspect_factor_count"])
|
||||
if lagna_score == seventh_score:
|
||||
return {
|
||||
"status": "blocked",
|
||||
"reason": "later_narayana_sign_strength_rules_required",
|
||||
"profile": "jaimini_lagna_seventh_strength_v1",
|
||||
"lagna_sign_idx": lagna_sign_idx,
|
||||
"seventh_sign_idx": seventh_sign_idx,
|
||||
"candidates": candidates,
|
||||
}
|
||||
selected_sign_idx = lagna_sign_idx if lagna_score > seventh_score else seventh_sign_idx
|
||||
return {
|
||||
"status": "selected",
|
||||
"profile": "jaimini_lagna_seventh_strength_v1",
|
||||
"selected_sign_idx": selected_sign_idx,
|
||||
"selected_sign": SIGNS[selected_sign_idx],
|
||||
"selection_levels": ["occupant_count", "jupiter_mercury_or_lord_rasi_aspect"],
|
||||
"candidates": candidates,
|
||||
}
|
||||
|
||||
|
||||
def calculate_narayana_duration(
|
||||
sign_idx: int,
|
||||
lord_sign_idx: int,
|
||||
*,
|
||||
profile: str = "legacy_forward_v0",
|
||||
) -> int:
|
||||
"""Return a Narayana sign-period duration under an explicit rule profile."""
|
||||
if profile not in _NARAYANA_DURATION_PROFILES:
|
||||
raise ValueError(f"unsupported Narayana duration profile: {profile}")
|
||||
if not 0 <= sign_idx < 12 or not 0 <= lord_sign_idx < 12:
|
||||
raise ValueError("sign indices must be in 0..11")
|
||||
if sign_idx == lord_sign_idx:
|
||||
return 12
|
||||
if profile == "legacy_forward_v0":
|
||||
return _count_signs_forward(sign_idx, lord_sign_idx)
|
||||
if sign_idx in _NARAYANA_ODD_FOOTED_SIGNS:
|
||||
return (lord_sign_idx - sign_idx) % 12
|
||||
return (sign_idx - lord_sign_idx) % 12
|
||||
|
||||
|
||||
def calculate_narayana_period_years(
|
||||
sign_idx: int,
|
||||
lord: str,
|
||||
lord_sign_idx: int,
|
||||
*,
|
||||
profile: str = "legacy_forward_v0",
|
||||
) -> tuple[int, int]:
|
||||
"""Return a period length and dignity adjustment under an explicit profile."""
|
||||
base_years = calculate_narayana_duration(sign_idx, lord_sign_idx, profile=profile)
|
||||
if profile != "jaimini_odd_footed_dignity_v2":
|
||||
return base_years, 0
|
||||
dignity = _NARAYANA_DIGNITY.get(lord, {})
|
||||
adjustment = 1 if lord_sign_idx in dignity.get("exalted", set()) else 0
|
||||
if lord_sign_idx in dignity.get("debilitated", set()):
|
||||
adjustment = -1
|
||||
return max(0, min(12, base_years + adjustment)), adjustment
|
||||
|
||||
|
||||
def _narayana_dual_lord_strength(
|
||||
lord: str,
|
||||
lord_sign_idx: int,
|
||||
planet_lons: Dict[str, float],
|
||||
) -> tuple[int, int, int]:
|
||||
occupants = sum(
|
||||
1 for planet in planet_lons if _get_planet_sign(planet, planet_lons) == lord_sign_idx
|
||||
)
|
||||
sign_lord = SIGN_LORDS[SIGNS[lord_sign_idx]]
|
||||
aspect_factors = sum(
|
||||
1
|
||||
for planet in dict.fromkeys(("Jupiter", "Mercury", sign_lord))
|
||||
if (planet_sign_idx := _get_planet_sign(planet, planet_lons)) is not None
|
||||
and _rasi_aspects(planet_sign_idx, lord_sign_idx)
|
||||
)
|
||||
dignity = 2 if lord_sign_idx in _NARAYANA_NODE_MOOLATRIKONA.get(lord, set()) else 0
|
||||
if lord_sign_idx in _NARAYANA_NODE_OWN_SIGNS.get(lord, set()):
|
||||
dignity = max(dignity, 1)
|
||||
return occupants, aspect_factors, dignity
|
||||
|
||||
|
||||
def _resolve_narayana_lord(
|
||||
sign_idx: int,
|
||||
planet_lons: Dict[str, float],
|
||||
*,
|
||||
dual_lord_profile: str,
|
||||
) -> str:
|
||||
if dual_lord_profile not in _NARAYANA_DUAL_LORD_PROFILES:
|
||||
raise ValueError(f"unsupported Narayana dual-lord profile: {dual_lord_profile}")
|
||||
primary_lord = SIGN_LORDS[SIGNS[sign_idx]]
|
||||
dual_lords = {7: ("Mars", "Ketu"), 10: ("Saturn", "Rahu")}.get(sign_idx)
|
||||
if dual_lords is None or dual_lord_profile == "legacy_primary_lord_v0":
|
||||
return primary_lord
|
||||
first, second = dual_lords
|
||||
first_sign = _get_planet_sign(first, planet_lons)
|
||||
second_sign = _get_planet_sign(second, planet_lons)
|
||||
if first_sign is None or second_sign is None:
|
||||
return primary_lord
|
||||
if first_sign == sign_idx and second_sign != sign_idx:
|
||||
return second
|
||||
if second_sign == sign_idx and first_sign != sign_idx:
|
||||
return first
|
||||
if first_sign == second_sign:
|
||||
return first
|
||||
first_strength = _narayana_dual_lord_strength(first, first_sign, planet_lons)
|
||||
second_strength = _narayana_dual_lord_strength(second, second_sign, planet_lons)
|
||||
if first_strength > second_strength:
|
||||
return first
|
||||
if second_strength > first_strength:
|
||||
return second
|
||||
first_years = calculate_narayana_duration(sign_idx, first_sign, profile="jaimini_odd_footed_v1")
|
||||
second_years = calculate_narayana_duration(sign_idx, second_sign, profile="jaimini_odd_footed_v1")
|
||||
return first if first_years >= second_years else second
|
||||
|
||||
|
||||
def calc_narayana_mahadasha(
|
||||
lagna_sign_idx: int,
|
||||
planet_lons: Dict[str, float],
|
||||
@@ -114,6 +483,11 @@ def calc_narayana_mahadasha(
|
||||
def calc_narayana_antardasha(
|
||||
mahadasha_periods: List[Dict],
|
||||
md_sign_idx: int,
|
||||
*,
|
||||
planet_lons: Optional[Dict[str, float]] = None,
|
||||
profile: str = "legacy_weighted_v0",
|
||||
dual_lord_profile: str = "legacy_primary_lord_v0",
|
||||
md_period: Optional[Dict] = None,
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
计算给定 Mahadasha 的 Antardasha 子周期。
|
||||
@@ -134,6 +508,41 @@ def calc_narayana_antardasha(
|
||||
if md is None:
|
||||
return []
|
||||
|
||||
if profile == "legacy_weighted_v0" and len({p['sign_idx'] for p in mahadasha_periods}) != len(mahadasha_periods):
|
||||
raise ValueError("legacy_weighted_v0 does not support repeated MD signs across cycles")
|
||||
|
||||
if profile in {"parashara_equal_v1", "pl9_observed_stronger_sign_v1", "pl9_observed_direction_v1", "pl9_observed_direction_tie_break_v1"}:
|
||||
if not planet_lons:
|
||||
raise ValueError(f"{profile} requires planet_lons")
|
||||
selection = select_narayana_seed_sign(md_sign_idx, planet_lons)
|
||||
if selection["status"] != "selected":
|
||||
raise ValueError(selection["reason"])
|
||||
stronger_sign_idx = selection["selected_sign_idx"]
|
||||
lord = _resolve_narayana_lord(stronger_sign_idx, planet_lons, dual_lord_profile=dual_lord_profile)
|
||||
if profile in {"parashara_equal_v1", "pl9_observed_direction_v1", "pl9_observed_direction_tie_break_v1"}:
|
||||
start_sign_idx = _get_planet_sign(lord, planet_lons)
|
||||
if start_sign_idx is None:
|
||||
raise ValueError(f"{profile} requires stronger-sign lord longitude")
|
||||
start_sign_source = "lord_of_stronger_dasha_or_seventh_sign"
|
||||
evidence_status = "sourced" if profile == "parashara_equal_v1" else "observed_pl9_control_case_only"
|
||||
else:
|
||||
start_sign_idx = stronger_sign_idx
|
||||
start_sign_source = "pl9_observed_stronger_dasha_or_seventh_sign"
|
||||
evidence_status = "parameter_sensitive"
|
||||
zodiacal_direction = md_sign_idx % 2 == 0
|
||||
if profile in {"pl9_observed_direction_v1", "pl9_observed_direction_tie_break_v1"}:
|
||||
zodiacal_direction = md_sign_idx in {3, 4, 9, 10}
|
||||
return _subdivide_narayana_period_equal(
|
||||
parent_period=md,
|
||||
start_sign_idx=start_sign_idx,
|
||||
zodiacal_direction=zodiacal_direction,
|
||||
stronger_sign_idx=stronger_sign_idx,
|
||||
stronger_sign_lord=lord,
|
||||
profile=profile,
|
||||
start_sign_source=start_sign_source,
|
||||
evidence_status=evidence_status,
|
||||
)
|
||||
|
||||
return _subdivide_narayana_period(
|
||||
mahadasha_periods=mahadasha_periods,
|
||||
parent_period=md,
|
||||
@@ -143,6 +552,47 @@ def calc_narayana_antardasha(
|
||||
)
|
||||
|
||||
|
||||
|
||||
def _subdivide_narayana_period_equal(
|
||||
*,
|
||||
parent_period: Dict,
|
||||
start_sign_idx: int,
|
||||
zodiacal_direction: bool,
|
||||
stronger_sign_idx: int,
|
||||
stronger_sign_lord: str,
|
||||
profile: str,
|
||||
start_sign_source: str,
|
||||
evidence_status: str,
|
||||
) -> List[Dict]:
|
||||
"""Apply BPHS's twelve equal-sign Antardasha rule for a Rashi Dasha."""
|
||||
total_years = float(parent_period.get("years", 0))
|
||||
parent_start = float(parent_period.get("start_age", 0))
|
||||
parent_end = float(parent_period.get("end_age", parent_start + total_years))
|
||||
direction = 1 if zodiacal_direction else -1
|
||||
sub_periods = []
|
||||
for index in range(12):
|
||||
sign_idx = (start_sign_idx + direction * index) % 12
|
||||
start_age = parent_start + total_years * index / 12
|
||||
end_age = parent_end if index == 11 else parent_start + total_years * (index + 1) / 12
|
||||
sub_periods.append({
|
||||
"sign": SIGNS[sign_idx],
|
||||
"sign_idx": sign_idx,
|
||||
"lord": SIGN_LORDS[SIGNS[sign_idx]],
|
||||
"years": round(max(0.0, end_age - start_age), 4),
|
||||
"start_age": round(start_age, 4),
|
||||
"end_age": round(end_age, 4),
|
||||
"parent_md": parent_period.get("sign", SIGNS[parent_period["sign_idx"]]),
|
||||
"sequence_index": index,
|
||||
"antardasha_profile": profile,
|
||||
"start_sign_source": start_sign_source,
|
||||
"evidence_status": evidence_status,
|
||||
"stronger_sign_idx": stronger_sign_idx,
|
||||
"stronger_sign_lord": stronger_sign_lord,
|
||||
"direction": "zodiacal" if zodiacal_direction else "reverse",
|
||||
})
|
||||
return sub_periods
|
||||
|
||||
|
||||
def calc_narayana_pratyantardasha(
|
||||
mahadasha_periods: List[Dict],
|
||||
antardasha_period: Dict,
|
||||
@@ -295,11 +745,110 @@ def get_current_narayana_dasha(
|
||||
return result
|
||||
|
||||
|
||||
def build_narayana_dense_boundaries(
|
||||
mahadasha_periods: List[Dict],
|
||||
*,
|
||||
planet_lons: Optional[Dict[str, float]] = None,
|
||||
antardasha_profile: str = "legacy_weighted_v0",
|
||||
dual_lord_profile: str = "legacy_primary_lord_v0",
|
||||
) -> List[Dict]:
|
||||
"""Return schema-stable MD/AD Narayana boundaries on the absolute age axis."""
|
||||
rows: List[Dict] = []
|
||||
for md_index, md in enumerate(mahadasha_periods, start=1):
|
||||
md_direction = md.get("direction")
|
||||
rows.append({
|
||||
"level": "MD",
|
||||
"sign": md.get("sign"),
|
||||
"sign_idx": md.get("sign_idx"),
|
||||
"lord": md.get("lord"),
|
||||
"start_age": md.get("start_age"),
|
||||
"end_age": md.get("end_age"),
|
||||
"duration_years": md.get("years"),
|
||||
"duration_days": round(float(md.get("years", 0)) * 365.25, 2),
|
||||
"direction": md_direction or md.get("sequence_profile"),
|
||||
"seed_sign": SIGNS[md.get("seed_sign_idx", md.get("sign_idx", 0))],
|
||||
"profile": {
|
||||
"duration_profile": md.get("duration_profile"),
|
||||
"sequence_profile": md.get("sequence_profile"),
|
||||
"seed_profile": md.get("seed_profile"),
|
||||
"dual_lord_profile": md.get("dual_lord_profile"),
|
||||
"antardasha_profile": antardasha_profile,
|
||||
},
|
||||
"source_formula_status": "local_md_profiled_v1",
|
||||
"period_key": md.get("period_key"),
|
||||
"sequence_index": md_index - 1,
|
||||
})
|
||||
try:
|
||||
antardashas = calc_narayana_antardasha(
|
||||
mahadasha_periods,
|
||||
md["sign_idx"],
|
||||
planet_lons=planet_lons,
|
||||
profile=antardasha_profile,
|
||||
dual_lord_profile=dual_lord_profile,
|
||||
md_period=md,
|
||||
)
|
||||
ad_status = "local_ad_profiled_v1"
|
||||
except ValueError as exc:
|
||||
rows.append({
|
||||
"level": "AD",
|
||||
"sign": None,
|
||||
"start_age": md.get("start_age"),
|
||||
"end_age": md.get("end_age"),
|
||||
"duration_years": None,
|
||||
"duration_days": None,
|
||||
"direction": None,
|
||||
"seed_sign": md.get("sign"),
|
||||
"profile": {
|
||||
"duration_profile": md.get("duration_profile"),
|
||||
"sequence_profile": md.get("sequence_profile"),
|
||||
"seed_profile": md.get("seed_profile"),
|
||||
"dual_lord_profile": dual_lord_profile,
|
||||
"antardasha_profile": antardasha_profile,
|
||||
},
|
||||
"source_formula_status": "blocked_antardasha_profile_error",
|
||||
"blocked_reason": str(exc),
|
||||
"parent_md": md.get("sign"),
|
||||
"parent_period_key": md.get("period_key"),
|
||||
})
|
||||
continue
|
||||
for ad_index, ad in enumerate(antardashas, start=1):
|
||||
rows.append({
|
||||
"level": "AD",
|
||||
"sign": ad.get("sign"),
|
||||
"sign_idx": ad.get("sign_idx"),
|
||||
"lord": ad.get("lord"),
|
||||
"start_age": ad.get("start_age"),
|
||||
"end_age": ad.get("end_age"),
|
||||
"duration_years": ad.get("years"),
|
||||
"duration_days": round(float(ad.get("years", 0)) * 365.25, 2),
|
||||
"direction": ad.get("direction") or md.get("sequence_profile"),
|
||||
"seed_sign": ad.get("start_sign_source") or md.get("sign"),
|
||||
"profile": {
|
||||
"duration_profile": md.get("duration_profile"),
|
||||
"sequence_profile": md.get("sequence_profile"),
|
||||
"seed_profile": md.get("seed_profile"),
|
||||
"dual_lord_profile": dual_lord_profile,
|
||||
"antardasha_profile": antardasha_profile,
|
||||
},
|
||||
"source_formula_status": ad.get("evidence_status", ad_status),
|
||||
"parent_md": md.get("sign"),
|
||||
"parent_period_key": md.get("period_key"),
|
||||
"sequence_index": ad_index - 1,
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def narayana_dasha_full_report(
|
||||
lagna_sign_idx: int,
|
||||
planet_lons: Dict[str, float],
|
||||
current_age: float = 0,
|
||||
birth_year: int = 0,
|
||||
duration_profile: str = "legacy_forward_v0",
|
||||
sequence_profile: str = "legacy_zodiacal_v0",
|
||||
seed_profile: str = "legacy_lagna_v0",
|
||||
dual_lord_profile: str = "legacy_primary_lord_v0",
|
||||
antardasha_profile: str = "legacy_weighted_v0",
|
||||
cycle_count: int = 1,
|
||||
) -> Dict:
|
||||
"""
|
||||
Narayana Dasha 完整报告。
|
||||
@@ -322,6 +871,29 @@ def narayana_dasha_full_report(
|
||||
result['total_cycle_years'] = total_cycle
|
||||
result['lagna_sign'] = SIGNS[lagna_sign_idx]
|
||||
result['lagna_sign_idx'] = lagna_sign_idx
|
||||
result['duration_profile'] = duration_profile
|
||||
result['sequence_profile'] = sequence_profile
|
||||
result['seed_profile'] = seed_profile
|
||||
result['dual_lord_profile'] = dual_lord_profile
|
||||
result['antardasha_profile'] = antardasha_profile
|
||||
result['cycle_count'] = cycle_count
|
||||
result['seed_selection'] = (
|
||||
select_narayana_seed_sign(lagna_sign_idx, planet_lons)
|
||||
if seed_profile == "jaimini_lagna_seventh_strength_v1"
|
||||
else {
|
||||
"status": "selected",
|
||||
"profile": "legacy_lagna_v0",
|
||||
"selected_sign_idx": lagna_sign_idx,
|
||||
"selected_sign": SIGNS[lagna_sign_idx],
|
||||
}
|
||||
)
|
||||
result['dense_boundaries'] = build_narayana_dense_boundaries(
|
||||
mahadasha,
|
||||
planet_lons=planet_lons,
|
||||
antardasha_profile=antardasha_profile,
|
||||
dual_lord_profile=dual_lord_profile,
|
||||
)
|
||||
result['dense_boundary_schema'] = "narayana_dense_boundaries.v1"
|
||||
|
||||
# 2. 当前大运
|
||||
if current_age > 0:
|
||||
|
||||
@@ -28,6 +28,16 @@ try:
|
||||
except ImportError: # pragma: no cover - package import path
|
||||
from scripts.consultation_domain_registry import CANONICAL_DOMAINS, normalize_domain
|
||||
|
||||
try:
|
||||
from report_language_contract import build_report_language_contract_audit
|
||||
except Exception: # pragma: no cover - module is research-only and not vendored
|
||||
try:
|
||||
from scripts.report_language_contract import build_report_language_contract_audit
|
||||
except Exception:
|
||||
def build_report_language_contract_audit(rendered):
|
||||
return {"status": "blocked", "reason": "report_language_contract_module_absent"}
|
||||
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# 核心枚举与常量
|
||||
@@ -1134,6 +1144,22 @@ def render_reader_report(reader_report: Dict[str, Any]) -> Dict[str, Any]:
|
||||
thematic_narrative = reader_report.get("thematic_narrative")
|
||||
supplied_appendix = reader_report.get("evidence_appendix")
|
||||
evidence_appendix = dict(supplied_appendix) if isinstance(supplied_appendix, dict) else {}
|
||||
dasha_boundary_packets = reader_report.get("dasha_boundary_packets")
|
||||
ashtottari_packet = reader_report.get("ashtottari_report_boundary_packet")
|
||||
startrack_language_bridge = reader_report.get("startrack_language_bridge")
|
||||
if (isinstance(dasha_boundary_packets, dict) and dasha_boundary_packets) or (isinstance(ashtottari_packet, dict) and ashtottari_packet):
|
||||
reference_parity = evidence_appendix.get("reference_parity")
|
||||
reference_parity = dict(reference_parity) if isinstance(reference_parity, dict) else {}
|
||||
if isinstance(dasha_boundary_packets, dict) and dasha_boundary_packets:
|
||||
reference_parity.setdefault("dasha_boundary_packets", dasha_boundary_packets)
|
||||
if isinstance(ashtottari_packet, dict) and ashtottari_packet:
|
||||
reference_parity.setdefault("ashtottari_pl9_boundary_packet", ashtottari_packet)
|
||||
evidence_appendix["reference_parity"] = reference_parity
|
||||
if isinstance(startrack_language_bridge, dict) and startrack_language_bridge:
|
||||
language_bridge = evidence_appendix.get("language_bridge")
|
||||
language_bridge = dict(language_bridge) if isinstance(language_bridge, dict) else {}
|
||||
language_bridge.setdefault("startrack_language_bridge", startrack_language_bridge)
|
||||
evidence_appendix["language_bridge"] = language_bridge
|
||||
presentation_mode = reader_report.get("presentation_mode")
|
||||
if not presentation_mode and isinstance(executive_summary, dict):
|
||||
presentation_mode = executive_summary.get("presentation_mode")
|
||||
@@ -1152,6 +1178,13 @@ def render_reader_report(reader_report: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"evidence_appendix": evidence_appendix,
|
||||
}
|
||||
rendered.update(extensions)
|
||||
language_bridge = rendered["evidence_appendix"].get("language_bridge")
|
||||
language_bridge = dict(language_bridge) if isinstance(language_bridge, dict) else {}
|
||||
language_bridge.setdefault(
|
||||
"report_language_contract_audit",
|
||||
build_report_language_contract_audit(rendered),
|
||||
)
|
||||
rendered["evidence_appendix"]["language_bridge"] = language_bridge
|
||||
return rendered
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
"""Shared report-pack contract normalizer.
|
||||
|
||||
This module intentionally stays thin: it does not compute astrology results and
|
||||
does not adjudicate pack truth. It converts existing pack-shaped dictionaries
|
||||
into one stable envelope that the final PL9-style renderer can consume.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from canonical_jyotish_profile import (
|
||||
build_canonical_jyotish_profile,
|
||||
build_disputed_method_policy,
|
||||
build_reader_engine_boundary_notice,
|
||||
)
|
||||
except Exception: # pragma: no cover - research helpers are not vendored here
|
||||
try:
|
||||
from scripts.canonical_jyotish_profile import (
|
||||
build_canonical_jyotish_profile,
|
||||
build_disputed_method_policy,
|
||||
build_reader_engine_boundary_notice,
|
||||
)
|
||||
except Exception:
|
||||
def build_canonical_jyotish_profile():
|
||||
return {"profile_id": "product_canonical", "status": "local_fallback"}
|
||||
|
||||
def build_disputed_method_policy():
|
||||
return {}
|
||||
|
||||
def build_reader_engine_boundary_notice():
|
||||
return {"status": "local_fallback"}
|
||||
try:
|
||||
from profile_aware_benchmark_boundary_dashboard import load_dashboard_payload
|
||||
except Exception: # pragma: no cover - research helpers are not vendored here
|
||||
try:
|
||||
from scripts.profile_aware_benchmark_boundary_dashboard import load_dashboard_payload
|
||||
except Exception:
|
||||
def load_dashboard_payload():
|
||||
return {"status": "blocked", "reason": "profile_aware_dashboard_absent"}
|
||||
|
||||
|
||||
SCHEMA = "pl9.unified_report_pack_contract.v1"
|
||||
|
||||
|
||||
def normalize_report_pack_contract(pack: Mapping[str, Any], *, pack_id: str) -> dict[str, Any]:
|
||||
"""Return a uniform report-ready envelope for an existing pack."""
|
||||
|
||||
report_sections = _normalize_report_sections(pack.get("report_sections"))
|
||||
audit = _normalize_audit(pack)
|
||||
pl9_pages = _as_list(pack.get("pl9_pages") or audit.get("pl9_pages"))
|
||||
blocked_reasons = _blocked_reasons(pack, report_sections)
|
||||
|
||||
contract = {
|
||||
"schema": SCHEMA,
|
||||
"pack_id": pack_id,
|
||||
"source_schema": pack.get("schema"),
|
||||
"status": _status(pack, audit),
|
||||
"profile": dict(pack.get("profile") or {}),
|
||||
"pl9_pages": pl9_pages,
|
||||
"summary": dict(pack.get("summary") or {}),
|
||||
"canonical_jyotish_profile": dict(pack.get("canonical_jyotish_profile") or build_canonical_jyotish_profile()),
|
||||
"reader_engine_boundary_notice": dict(
|
||||
pack.get("reader_engine_boundary_notice") or build_reader_engine_boundary_notice()
|
||||
),
|
||||
"multi_engine_difference_notice": _multi_engine_difference_notice(pack),
|
||||
"profile_aware_dashboard": dict(pack.get("profile_aware_dashboard") or load_dashboard_payload()),
|
||||
"raw_data": _raw_data(pack),
|
||||
"normalized_data": _normalized_data(pack),
|
||||
"report_sections": report_sections,
|
||||
"exports": _normalize_exports(pack.get("exports")),
|
||||
"audit": audit,
|
||||
"blocked_reasons": blocked_reasons,
|
||||
"contract_audit": {
|
||||
"missing_fields": _missing_fields(report_sections),
|
||||
"source_pack_keys": list(pack.keys()),
|
||||
"normalization_boundary": "contract_only_no_astrological_recalculation",
|
||||
},
|
||||
}
|
||||
return contract
|
||||
|
||||
|
||||
def _status(pack: Mapping[str, Any], audit: Mapping[str, Any]) -> str:
|
||||
explicit = pack.get("status") or audit.get("status")
|
||||
if explicit:
|
||||
return str(explicit)
|
||||
|
||||
statuses: list[str] = []
|
||||
report_sections = pack.get("report_sections") if isinstance(pack.get("report_sections"), Mapping) else {}
|
||||
for key in ("executive_summary", "thematic_narrative", "evidence_appendix", "pdf_sections"):
|
||||
section = report_sections.get(key)
|
||||
if isinstance(section, Mapping) and section.get("status"):
|
||||
statuses.append(str(section.get("status")))
|
||||
for key, value in pack.items():
|
||||
if key in {"schema", "profile", "pl9_pages", "summary", "report_sections", "exports", "audit"}:
|
||||
continue
|
||||
if isinstance(value, Mapping) and value.get("status"):
|
||||
statuses.append(str(value.get("status")))
|
||||
|
||||
if not statuses:
|
||||
return "blocked"
|
||||
if any(status in {"conflict", "parameter_sensitive"} for status in statuses):
|
||||
return "parameter_sensitive"
|
||||
if any(status in {"partial_verified", "verified"} for status in statuses):
|
||||
return "partial_verified"
|
||||
if any(status == "not_applicable" for status in statuses):
|
||||
return "not_applicable"
|
||||
return "blocked"
|
||||
|
||||
|
||||
def _normalize_report_sections(value: Any) -> dict[str, Any]:
|
||||
sections = dict(value or {}) if isinstance(value, Mapping) else {}
|
||||
executive = sections.get("executive_summary")
|
||||
thematic = sections.get("thematic_narrative")
|
||||
evidence = sections.get("evidence_appendix")
|
||||
pdf_sections = sections.get("pdf_sections")
|
||||
|
||||
if isinstance(executive, Mapping):
|
||||
executive = [str(executive.get("headline") or executive.get("status") or "")]
|
||||
elif isinstance(executive, str):
|
||||
executive = [executive]
|
||||
elif executive is None:
|
||||
executive = []
|
||||
else:
|
||||
executive = list(executive)
|
||||
|
||||
if thematic is None:
|
||||
thematic = sections.get("trigger_seed_narrative") or []
|
||||
if evidence is None:
|
||||
evidence = []
|
||||
for key in ("visual_chart_audit", "audit_appendix"):
|
||||
if key in sections:
|
||||
evidence.append(sections[key])
|
||||
|
||||
return {
|
||||
"executive_summary": executive,
|
||||
"thematic_narrative": list(thematic) if isinstance(thematic, list) else _as_list(thematic),
|
||||
"evidence_appendix": list(evidence) if isinstance(evidence, list) else _as_list(evidence),
|
||||
"pdf_sections": list(pdf_sections) if isinstance(pdf_sections, list) else _as_list(pdf_sections),
|
||||
}
|
||||
|
||||
|
||||
def _normalize_exports(value: Any) -> dict[str, Any]:
|
||||
exports = dict(value or {}) if isinstance(value, Mapping) else {}
|
||||
return {
|
||||
"json": exports.get("json"),
|
||||
"markdown": exports.get("markdown"),
|
||||
"pdf_sections": exports.get("pdf_sections"),
|
||||
"ai_evidence_bundle": dict(exports.get("ai_evidence_bundle") or {}),
|
||||
}
|
||||
|
||||
|
||||
def _multi_engine_difference_notice(pack: Mapping[str, Any]) -> dict[str, Any]:
|
||||
if isinstance(pack.get("multi_engine_difference_notice"), Mapping):
|
||||
return dict(pack["multi_engine_difference_notice"])
|
||||
policy = build_disputed_method_policy()
|
||||
lanes = []
|
||||
for lane_id, lane in policy.items():
|
||||
lanes.append(
|
||||
{
|
||||
"lane_id": lane_id,
|
||||
"canonical_standard": lane["canonical_standard"],
|
||||
"external_difference_display": "external_observation_conflict",
|
||||
"report_assertion_ceiling": lane["report_assertion_ceiling"],
|
||||
"reader_text_zh": (
|
||||
f"{lane_id} 主口径按 {lane['canonical_standard']};"
|
||||
"其他引擎若不同,会显示为外部观察冲突,不直接改写主结论。"
|
||||
),
|
||||
}
|
||||
)
|
||||
return {
|
||||
"status": "active",
|
||||
"purpose": "reader_safe_multi_engine_difference_display",
|
||||
"canonical_profile_id": build_canonical_jyotish_profile()["profile_id"],
|
||||
"lanes": lanes,
|
||||
"must_not_claim": [
|
||||
"external_engine_conflict_overrides_canonical_result",
|
||||
"internal_consistency_is_global_truth_closure",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _normalize_audit(pack: Mapping[str, Any]) -> dict[str, Any]:
|
||||
audit = dict(pack.get("audit") or {})
|
||||
must_not_claim = list(audit.get("must_not_claim") or [])
|
||||
sections = pack.get("report_sections") if isinstance(pack.get("report_sections"), Mapping) else {}
|
||||
for key in ("visual_chart_audit", "evidence_audit"):
|
||||
section = sections.get(key) if isinstance(sections, Mapping) else None
|
||||
if isinstance(section, Mapping):
|
||||
must_not_claim.extend(item for item in section.get("must_not_claim") or [] if item not in must_not_claim)
|
||||
if must_not_claim:
|
||||
audit["must_not_claim"] = must_not_claim
|
||||
return audit
|
||||
|
||||
|
||||
def _blocked_reasons(pack: Mapping[str, Any], report_sections: Mapping[str, Any]) -> list[str]:
|
||||
reasons: list[str] = []
|
||||
for key in ("blocked_reasons", "blocked_fields"):
|
||||
reasons.extend(str(item) for item in pack.get(key) or [])
|
||||
original_sections = pack.get("report_sections") if isinstance(pack.get("report_sections"), Mapping) else {}
|
||||
executive = original_sections.get("executive_summary") if isinstance(original_sections, Mapping) else None
|
||||
if isinstance(executive, Mapping):
|
||||
reasons.extend(str(item) for item in executive.get("blocked_fields") or [])
|
||||
if not reasons and _status(pack, pack.get("audit") or {}) == "blocked":
|
||||
for item in report_sections.get("evidence_appendix") or []:
|
||||
if isinstance(item, str):
|
||||
reasons.append(item)
|
||||
return list(dict.fromkeys(reasons))
|
||||
|
||||
|
||||
def _raw_data(pack: Mapping[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
key: value
|
||||
for key, value in pack.items()
|
||||
if key
|
||||
not in {
|
||||
"schema",
|
||||
"status",
|
||||
"profile",
|
||||
"pl9_pages",
|
||||
"summary",
|
||||
"report_sections",
|
||||
"exports",
|
||||
"audit",
|
||||
"profile_aware_dashboard",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _normalized_data(pack: Mapping[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"source_schema": pack.get("schema"),
|
||||
"status": pack.get("status") or (pack.get("audit") or {}).get("status") if isinstance(pack.get("audit"), Mapping) else pack.get("status"),
|
||||
}
|
||||
|
||||
|
||||
def _missing_fields(report_sections: Mapping[str, Any]) -> list[str]:
|
||||
missing = []
|
||||
for key in ("executive_summary", "thematic_narrative", "evidence_appendix"):
|
||||
if not report_sections.get(key):
|
||||
missing.append(key)
|
||||
return missing
|
||||
|
||||
|
||||
def _as_list(value: Any) -> list[Any]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
if isinstance(value, tuple):
|
||||
return list(value)
|
||||
return [value]
|
||||
@@ -145,6 +145,7 @@ RELEASE_CRITICAL_UNTRACKED_PATHS = [
|
||||
|
||||
QUALITY_GATE_PROFILES = {
|
||||
"quick": {
|
||||
"test_timeout_seconds": 90,
|
||||
"skip_slow": True,
|
||||
"skip_yoga_logic": True,
|
||||
"skip_frontend_runtime": False,
|
||||
@@ -156,6 +157,7 @@ QUALITY_GATE_PROFILES = {
|
||||
"skip_vedastro_live": True,
|
||||
},
|
||||
"browser": {
|
||||
"test_timeout_seconds": 120,
|
||||
"skip_slow": True,
|
||||
"skip_yoga_logic": True,
|
||||
"skip_frontend_runtime": False,
|
||||
@@ -167,6 +169,7 @@ QUALITY_GATE_PROFILES = {
|
||||
"skip_vedastro_live": True,
|
||||
},
|
||||
"release": {
|
||||
"test_timeout_seconds": 600,
|
||||
"skip_slow": False,
|
||||
"skip_yoga_logic": False,
|
||||
"skip_frontend_runtime": False,
|
||||
@@ -178,6 +181,7 @@ QUALITY_GATE_PROFILES = {
|
||||
"skip_vedastro_live": True,
|
||||
},
|
||||
"accuracy": {
|
||||
"test_timeout_seconds": 300,
|
||||
"skip_slow": True,
|
||||
"skip_yoga_logic": False,
|
||||
"skip_frontend_runtime": True,
|
||||
@@ -189,6 +193,7 @@ QUALITY_GATE_PROFILES = {
|
||||
"skip_vedastro_live": True,
|
||||
},
|
||||
"vedastro-live": {
|
||||
"test_timeout_seconds": 120,
|
||||
"skip_slow": True,
|
||||
"skip_yoga_logic": True,
|
||||
"skip_frontend_runtime": True,
|
||||
@@ -200,6 +205,7 @@ QUALITY_GATE_PROFILES = {
|
||||
"skip_vedastro_live": False,
|
||||
},
|
||||
"runtime-truth": {
|
||||
"test_timeout_seconds": 180,
|
||||
"skip_slow": True,
|
||||
"skip_yoga_logic": True,
|
||||
"skip_frontend_runtime": True,
|
||||
@@ -515,6 +521,13 @@ def main() -> int:
|
||||
parser.add_argument("--skip-local-accuracy-report", action="store_true", help="Skip consolidated local accuracy report")
|
||||
parser.add_argument("--skip-vedastro-live", action="store_true", help="Skip optional VedAstro live endpoint smoke")
|
||||
parser.add_argument("--all-tests", action="store_true", help="Run every pytest file, including optional-dependency suites")
|
||||
parser.add_argument(
|
||||
"--test-timeout",
|
||||
type=float,
|
||||
default=None,
|
||||
metavar="SECONDS",
|
||||
help="Fail an individual pytest call after SECONDS; defaults to the selected profile boundary.",
|
||||
)
|
||||
parser.add_argument("--require-external-parity", action="store_true", help="Fail the release gate unless the three-engine raw parity manifest passes.")
|
||||
args = parser.parse_args()
|
||||
profile = run_profile(args)
|
||||
|
||||
+58
-26
@@ -70,6 +70,24 @@ def _estimate_sun_sign(birth_month: int, birth_day: int) -> int:
|
||||
return 0 # fallback
|
||||
|
||||
|
||||
def _muntha_from_natal_ascendant(natal_asc_sign_idx: int, completed_years: int) -> Dict:
|
||||
"""Advance Muntha from the natal ascendant, one sign per completed year."""
|
||||
muntha_sign_idx = (int(natal_asc_sign_idx) + int(completed_years)) % 12
|
||||
return {
|
||||
'status': 'used',
|
||||
'muntha_sign_idx': muntha_sign_idx,
|
||||
'muntha_sign': SIGNS[muntha_sign_idx],
|
||||
'muntha_lord': SIGN_LORDS[SIGNS[muntha_sign_idx]],
|
||||
'natal_asc_sign_idx': int(natal_asc_sign_idx),
|
||||
'natal_asc_sign': SIGNS[int(natal_asc_sign_idx)],
|
||||
'age': int(completed_years),
|
||||
'formula': (
|
||||
f'(natal ascendant {SIGNS[int(natal_asc_sign_idx)]} + '
|
||||
f'completed years {int(completed_years)}) mod 12 = {SIGNS[muntha_sign_idx]}'
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 工具函数
|
||||
# =========================================================================
|
||||
@@ -115,7 +133,7 @@ def _datetime_to_jd_ut(dt: datetime) -> float:
|
||||
|
||||
|
||||
def _get_sun_lon_jd(jd_ut: float, ayanamsa_name: str = 'lahiri') -> Optional[float]:
|
||||
"""计算给定 JD (UT) 的太阳恒星黄经(Lahiri)"""
|
||||
"""计算给定 JD (UT) 的太阳恒星黄经(默认 Raman)"""
|
||||
if not HAS_SWE:
|
||||
return None
|
||||
try:
|
||||
@@ -138,7 +156,7 @@ def find_solar_return_ut(
|
||||
tz: float = 0.0,
|
||||
max_iter: int = 30,
|
||||
tol_deg: float = 0.0003, # ~1 arcsec
|
||||
ayanamsa_name: str = 'lahiri',
|
||||
ayanamsa_name: str = 'raman',
|
||||
) -> Dict:
|
||||
"""
|
||||
计算太阳返照(Solar Return)精确 UT 时刻。
|
||||
@@ -176,7 +194,7 @@ def _find_solar_return_swe(
|
||||
target_year: int,
|
||||
max_iter: int,
|
||||
tol_deg: float,
|
||||
ayanamsa_name: str = 'lahiri',
|
||||
ayanamsa_name: str = 'raman',
|
||||
) -> Dict:
|
||||
"""使用 swisseph 精确计算太阳返照时刻(Newton 迭代法)"""
|
||||
# 近似起始点:出生日期在目标年份的同一天
|
||||
@@ -278,7 +296,7 @@ def calc_solar_return_chart(
|
||||
birth_hour: int, birth_minute: int,
|
||||
birth_lat: float, birth_lon: float, birth_tz: float,
|
||||
target_year: int,
|
||||
ayanamsa_name: str = 'lahiri',
|
||||
ayanamsa_name: str = 'raman',
|
||||
) -> Dict:
|
||||
"""
|
||||
计算太阳返照盘(Varshaphala)。
|
||||
@@ -402,7 +420,7 @@ def solar_return_full_report(
|
||||
birth_hour: int, birth_minute: int,
|
||||
birth_lat: float, birth_lon: float, birth_tz: float,
|
||||
target_year: int,
|
||||
ayanamsa_name: str = 'lahiri',
|
||||
ayanamsa_name: str = 'raman',
|
||||
) -> Dict:
|
||||
"""
|
||||
太阳返照盘完整报告(Varshaphala 年运分析)。
|
||||
@@ -456,27 +474,31 @@ def solar_return_full_report(
|
||||
'age': sr['age'],
|
||||
}
|
||||
planet_lons = sr['planet_lons']
|
||||
sr_planets = sr.get('chart', {}).get('planets', {})
|
||||
tajika_planets = {
|
||||
pn: {
|
||||
'longitude': pd.get('degree_raw', pd.get('degree')),
|
||||
'speed': pd.get('speed'),
|
||||
}
|
||||
for pn, pd in sr_planets.items()
|
||||
if pn in ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn')
|
||||
and isinstance(pd, dict)
|
||||
and pd.get('degree_raw', pd.get('degree')) is not None
|
||||
and pd.get('speed') is not None
|
||||
}
|
||||
asc_sign_idx = sr['asc_sign_idx']
|
||||
|
||||
# 2. Muntha(正确算法:从返照盘太阳星座开始数)
|
||||
# 2. Muntha advances from the natal ascendant, not the solar sign.
|
||||
try:
|
||||
# Solar return sun sign = birth sun sign (by definition of solar return)
|
||||
sr_sun_sign = int(planet_lons.get('Sun', 0) / 30) % 12
|
||||
muntha_sign = (sr_sun_sign + age) % 12
|
||||
muntha_note = ''
|
||||
if degraded:
|
||||
muntha_note = '(基于查表法估算太阳星座,实际Muntha可能偏差±1星座)'
|
||||
result['muntha'] = {
|
||||
'muntha_sign_idx': muntha_sign,
|
||||
'muntha_sign': SIGNS[muntha_sign],
|
||||
'muntha_lord': SIGN_LORDS[SIGNS[muntha_sign]],
|
||||
'sr_sun_sign_idx': sr_sun_sign,
|
||||
'sr_sun_sign': SIGNS[sr_sun_sign],
|
||||
'age': age,
|
||||
'formula': f'(返照盘太阳星座{sr_sun_sign} + 年龄{age}) mod 12 = {muntha_sign}',
|
||||
'interpretation': _interpret_muntha_sign(muntha_sign),
|
||||
'note': muntha_note,
|
||||
}
|
||||
natal_asc = sr.get('birth_asc_sign_idx') if not degraded else None
|
||||
if natal_asc is None:
|
||||
result['muntha'] = {
|
||||
'status': 'blocked',
|
||||
'reason': 'natal_ascendant_required_for_muntha',
|
||||
}
|
||||
else:
|
||||
result['muntha'] = _muntha_from_natal_ascendant(natal_asc, age)
|
||||
result['muntha']['interpretation'] = _interpret_muntha_sign(result['muntha']['muntha_sign_idx'])
|
||||
except Exception as e:
|
||||
result['muntha'] = {'error': str(e)}
|
||||
|
||||
@@ -500,7 +522,7 @@ def solar_return_full_report(
|
||||
else:
|
||||
try:
|
||||
from tajika import calc_tajika_yogas
|
||||
yogas = calc_tajika_yogas(planet_lons, chart_type='varsha')
|
||||
yogas = calc_tajika_yogas(tajika_planets, chart_type='varsha')
|
||||
result['tajika_yogas'] = yogas
|
||||
except Exception as e:
|
||||
result['tajika_yogas'] = {'error': str(e)}
|
||||
@@ -512,8 +534,18 @@ def solar_return_full_report(
|
||||
try:
|
||||
from tajika import calc_all_sahams
|
||||
asc_lon = sr['ascendant'].get('lon', sr['ascendant'].get('degree', 0))
|
||||
sr_dt_ut = sr['solar_return']['dt_ut']
|
||||
sahams = calc_all_sahams(planet_lons, asc_lon, sr_dt_ut, chart_type='varsha')
|
||||
sr_dt_local = sr['solar_return'].get('dt_local')
|
||||
if sr_dt_local is None:
|
||||
raise ValueError('solar_return_local_datetime_required_for_saham_daynight')
|
||||
sahams = calc_all_sahams(
|
||||
planet_lons,
|
||||
asc_lon,
|
||||
sr_dt_local,
|
||||
chart_type='varsha',
|
||||
lat=birth_lat,
|
||||
lon=birth_lon,
|
||||
tz=birth_tz,
|
||||
)
|
||||
result['sahams'] = sahams
|
||||
except Exception as e:
|
||||
result['sahams'] = {'error': str(e)}
|
||||
|
||||
+247
-32
@@ -17,6 +17,9 @@ from datetime import datetime
|
||||
import math
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
SIGNS = ['Aries','Taurus','Gemini','Cancer','Leo','Virgo',
|
||||
'Libra','Scorpio','Sagittarius','Capricorn','Aquarius','Pisces']
|
||||
@@ -671,7 +674,7 @@ def calc_tajika_yogas(planet_lons: Dict[str, float],
|
||||
results['yogas'].append(yoga)
|
||||
|
||||
# ── 2. Easarapha Yoga(分离瑜伽)──
|
||||
# 定义:两行星同星座,快行星已离开慢行星(separate),度数差<=3度
|
||||
# 定义:两行星同星座,且 shared motion contract 判定为 separating
|
||||
for i, p1 in enumerate(Planet_LIST):
|
||||
if p1 not in planet_lons:
|
||||
continue
|
||||
@@ -686,28 +689,23 @@ def calc_tajika_yogas(planet_lons: Dict[str, float],
|
||||
|
||||
fast = p1 if _is_faster(p1, p2) else p2
|
||||
slow = p2 if fast == p1 else p1
|
||||
|
||||
fast_lon = planet_lons[fast]
|
||||
slow_lon = planet_lons[slow]
|
||||
# 分离:快行星已过慢行星(在慢行星"后面")
|
||||
# 即慢行星度数 > 快行星度数(在同一圈内)
|
||||
slow_in_sign = slow_lon % 30
|
||||
fast_in_sign = fast_lon % 30
|
||||
if slow_in_sign > fast_in_sign:
|
||||
# 慢在前面,快在后面 → 分离状态
|
||||
diff = slow_in_sign - fast_in_sign
|
||||
if diff <= 3.0:
|
||||
yoga = {
|
||||
'type': 'Easarapha',
|
||||
'fast_planet': fast,
|
||||
'slow_planet': slow,
|
||||
'degree_diff': round(diff, 4),
|
||||
'sign': SIGNS[sign1],
|
||||
'strength': 'strong' if diff <= 1.0 else 'moderate',
|
||||
'interpretation': _easarapha_interp(fast, slow, diff),
|
||||
}
|
||||
results['easarapha'].append(yoga)
|
||||
results['yogas'].append(yoga)
|
||||
shared_pair = evaluate_tajika_pair(p1, p2, motion_payload)
|
||||
if shared_pair is None or shared_pair.get('motion') != 'separating':
|
||||
continue
|
||||
diff = abs(float(shared_pair.get('residual', 0.0)))
|
||||
yoga = {
|
||||
'type': 'Easarapha',
|
||||
'fast_planet': fast,
|
||||
'slow_planet': slow,
|
||||
'degree_diff': round(diff, 4),
|
||||
'sign': SIGNS[sign1],
|
||||
'strength': 'strong' if diff <= 1.0 else 'moderate',
|
||||
'interpretation': _easarapha_interp(fast, slow, diff),
|
||||
'shared_motion': shared_pair.get('motion'),
|
||||
'shared_average_deeptamsa': shared_pair.get('average_deeptamsa'),
|
||||
}
|
||||
results['easarapha'].append(yoga)
|
||||
results['yogas'].append(yoga)
|
||||
|
||||
# ── 3. Nakta Yoga(夜间瑜伽)──
|
||||
# 定义:太阳和月亮同星座(任何度数)
|
||||
@@ -909,6 +907,206 @@ def _is_daytime(birth_dt: datetime, sun_lon: float, asc_lon: float) -> bool:
|
||||
return sun_house in (1, 2, 3, 4, 5, 6)
|
||||
|
||||
|
||||
def _year_lord_is_daytime(
|
||||
*,
|
||||
annual_dt: Optional[datetime],
|
||||
lat: Optional[float],
|
||||
lon: Optional[float],
|
||||
tz: Optional[float],
|
||||
sun_lon: float,
|
||||
asc_lon: float,
|
||||
) -> bool:
|
||||
if annual_dt is not None and lat is not None and lon is not None and tz is not None:
|
||||
try:
|
||||
from saham_daynight import determine_daytime
|
||||
return bool(determine_daytime(annual_dt, lat=float(lat), lon=float(lon), tz=float(tz))['is_daytime'])
|
||||
except Exception:
|
||||
pass
|
||||
if annual_dt is None:
|
||||
annual_dt = datetime.utcnow()
|
||||
return _is_daytime(annual_dt, sun_lon, asc_lon)
|
||||
|
||||
|
||||
def _benefic_aspect_targets(source_sign_idx: int) -> List[int]:
|
||||
source_sign_idx = int(source_sign_idx) % 12
|
||||
return [
|
||||
(source_sign_idx + 4) % 12,
|
||||
(source_sign_idx + 8) % 12,
|
||||
(source_sign_idx + 2) % 12,
|
||||
(source_sign_idx + 10) % 12,
|
||||
]
|
||||
|
||||
|
||||
def _malefic_aspect_targets(source_sign_idx: int) -> List[int]:
|
||||
source_sign_idx = int(source_sign_idx) % 12
|
||||
return [
|
||||
source_sign_idx,
|
||||
(source_sign_idx + 3) % 12,
|
||||
(source_sign_idx + 9) % 12,
|
||||
(source_sign_idx + 6) % 12,
|
||||
]
|
||||
|
||||
|
||||
def _resolve_year_lord_panchavargiya_score(
|
||||
planet: str,
|
||||
lon_value: float,
|
||||
*,
|
||||
annual_jd: Optional[float],
|
||||
lat: Optional[float],
|
||||
lon: Optional[float],
|
||||
tz: Optional[float],
|
||||
) -> Dict:
|
||||
external = _external_pyjhora_panchavargiya_score(
|
||||
planet,
|
||||
annual_jd=annual_jd,
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
tz=tz,
|
||||
)
|
||||
if external is not None:
|
||||
return external
|
||||
proxy = _calc_panchavargiya_bala_for_planet(planet, lon_value)
|
||||
return {
|
||||
'score': proxy['score'],
|
||||
'engine': 'native_proxy',
|
||||
'components': proxy['components'],
|
||||
}
|
||||
|
||||
|
||||
def _external_pyjhora_panchavargiya_score(
|
||||
planet: str,
|
||||
*,
|
||||
annual_jd: Optional[float],
|
||||
lat: Optional[float],
|
||||
lon: Optional[float],
|
||||
tz: Optional[float],
|
||||
) -> Optional[Dict]:
|
||||
if annual_jd is None or lat is None or lon is None or tz is None:
|
||||
return None
|
||||
try:
|
||||
from jhora.panchanga import drik as j_drik
|
||||
from jhora.horoscope.chart import strength as j_strength
|
||||
place = j_drik.Place('annual_year_lord_probe', float(lat), float(lon), float(tz))
|
||||
scores = j_strength.pancha_vargeeya_bala(float(annual_jd), place)
|
||||
planet_idx = PLANET_TO_INDEX.get(planet)
|
||||
if planet_idx is None:
|
||||
return None
|
||||
score = scores[planet_idx]
|
||||
return {
|
||||
'score': float(score),
|
||||
'engine': 'pyjhora',
|
||||
'components': {'pyjhora_pancha_vargeeya_bala': float(score)},
|
||||
}
|
||||
except Exception:
|
||||
return _external_pyjhora_panchavargiya_score_sidecar(planet, annual_jd, lat, lon, tz)
|
||||
|
||||
|
||||
def _external_pyjhora_panchavargiya_score_sidecar(
|
||||
planet: str,
|
||||
annual_jd: float,
|
||||
lat: float,
|
||||
lon: float,
|
||||
tz: float,
|
||||
) -> Optional[Dict]:
|
||||
"""Read optional PyJHora Panchavargiya Bala scores via an external Python.
|
||||
|
||||
PyJHora stays an optional black-box oracle. We do not vendor it, import it
|
||||
as a hard dependency, or fail native execution when it is absent.
|
||||
"""
|
||||
scores = _external_pyjhora_panchavargiya_scores_sidecar(annual_jd, lat, lon, tz)
|
||||
if not scores or planet not in scores:
|
||||
return None
|
||||
score = scores[planet]
|
||||
return {
|
||||
'score': score,
|
||||
'engine': 'pyjhora_sidecar',
|
||||
'components': {
|
||||
'pyjhora_pancha_vargeeya_bala_sidecar': score,
|
||||
'scores_by_planet': scores,
|
||||
'license_boundary': 'external_reference_only_do_not_vendor',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _external_pyjhora_panchavargiya_scores_sidecar(
|
||||
annual_jd: float,
|
||||
lat: float,
|
||||
lon: float,
|
||||
tz: float,
|
||||
) -> Optional[Dict[str, float]]:
|
||||
key = (round(float(annual_jd), 8), round(float(lat), 6), round(float(lon), 6), round(float(tz), 4))
|
||||
if key in _PYJHORA_PVB_SIDECAR_CACHE:
|
||||
return _PYJHORA_PVB_SIDECAR_CACHE[key]
|
||||
executable = _resolve_external_pyjhora_python()
|
||||
if not executable:
|
||||
_PYJHORA_PVB_SIDECAR_CACHE[key] = None
|
||||
return None
|
||||
code = """
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
from jhora.panchanga import drik as j_drik
|
||||
from jhora.horoscope.chart import strength as j_strength
|
||||
place = j_drik.Place('annual_year_lord_probe', float(sys.argv[2]), float(sys.argv[3]), float(sys.argv[4]))
|
||||
scores = j_strength.pancha_vargeeya_bala(float(sys.argv[1]), place)
|
||||
print(json.dumps({str(k): float(v) for k, v in dict(scores).items()}))
|
||||
"""
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
[executable, '-c', code, str(float(annual_jd)), str(float(lat)), str(float(lon)), str(float(tz))],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
)
|
||||
parsed = json.loads(completed.stdout)
|
||||
scores = {
|
||||
INDEX_TO_PLANET[int(idx)]: float(score)
|
||||
for idx, score in parsed.items()
|
||||
if int(idx) in INDEX_TO_PLANET
|
||||
}
|
||||
_PYJHORA_PVB_SIDECAR_CACHE[key] = scores or None
|
||||
return _PYJHORA_PVB_SIDECAR_CACHE[key]
|
||||
except Exception:
|
||||
_PYJHORA_PVB_SIDECAR_CACHE[key] = None
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_external_pyjhora_python() -> Optional[str]:
|
||||
candidates = [
|
||||
os.environ.get('JYOTISH_PYJHORA_PYTHON'),
|
||||
shutil.which('python3.14'),
|
||||
shutil.which('python3'),
|
||||
]
|
||||
current = os.path.abspath(sys.executable)
|
||||
for candidate in candidates:
|
||||
if not candidate:
|
||||
continue
|
||||
candidate = os.path.abspath(candidate)
|
||||
if not os.path.exists(candidate):
|
||||
continue
|
||||
# Do not compare realpaths here: a venv executable can resolve to the
|
||||
# managed Python binary while still having a different site-packages
|
||||
# view. The direct managed interpreter may have optional PyJHora even
|
||||
# when the current venv does not.
|
||||
if candidate == current:
|
||||
continue
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
[candidate, '-c', "import importlib.util; raise SystemExit(0 if importlib.util.find_spec('jhora') else 1)"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=8,
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
if completed.returncode == 0:
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def calc_all_sahams(planet_lons: Dict[str, float],
|
||||
asc_lon: float,
|
||||
birth_dt: datetime,
|
||||
@@ -1239,10 +1437,30 @@ def detect_tajika_yogas(varsha_planets: Dict, year_lord: str = None) -> List[Dic
|
||||
"""计算两星间的有效容许度(取较小者)"""
|
||||
return min(TAJIKA_ORBS.get(p1, 7), TAJIKA_ORBS.get(p2, 7))
|
||||
|
||||
def _build_shared_motion_payload():
|
||||
try:
|
||||
from scripts.tajika_kernel import evaluate_tajika_pair
|
||||
except ModuleNotFoundError: # pragma: no cover
|
||||
from tajika_kernel import evaluate_tajika_pair
|
||||
|
||||
speeds = {
|
||||
'Moon': 13.176, 'Mercury': 4.092, 'Venus': 1.602,
|
||||
'Sun': 0.986, 'Mars': 0.524, 'Jupiter': 0.083, 'Saturn': 0.034,
|
||||
}
|
||||
payload = {
|
||||
planet: {
|
||||
'longitude': _get_longitude(planet),
|
||||
'speed': speeds.get(planet, 0.0),
|
||||
}
|
||||
for planet in SEVEN
|
||||
}
|
||||
return evaluate_tajika_pair, payload
|
||||
|
||||
# ── 1. Ithasala Yoga(连接瑜伽)完整版 ──
|
||||
# 条件:快星追赶慢星(applying),orb ≤ 有效容许度
|
||||
ithasala_pairs = []
|
||||
checked = set()
|
||||
evaluate_tajika_pair, motion_payload = _build_shared_motion_payload()
|
||||
for p1 in SEVEN:
|
||||
for p2 in SEVEN:
|
||||
if p1 >= p2:
|
||||
@@ -1261,17 +1479,14 @@ def detect_tajika_yogas(varsha_planets: Dict, year_lord: str = None) -> List[Dic
|
||||
|
||||
fast = p1 if _is_faster(p1, p2) else p2
|
||||
slow = p2 if fast == p1 else p1
|
||||
fast_lon = _get_longitude(fast)
|
||||
slow_lon = _get_longitude(slow)
|
||||
|
||||
# 判断是否applying(快追慢)
|
||||
# 快星度数 < 慢星度数(同一方向)= applying
|
||||
applying = (fast_lon % 30) < (slow_lon % 30)
|
||||
|
||||
if applying or orb <= 3.0: # 3°内视为紧密连接
|
||||
shared_pair = evaluate_tajika_pair(p1, p2, motion_payload)
|
||||
if shared_pair is not None and shared_pair.get('motion') == 'applying':
|
||||
ithasala_pairs.append({
|
||||
'fast': fast, 'slow': slow, 'orb': orb,
|
||||
'fast_lon': fast_lon, 'slow_lon': slow_lon,
|
||||
'fast_lon': _get_longitude(fast), 'slow_lon': _get_longitude(slow),
|
||||
'shared_motion': shared_pair.get('motion'),
|
||||
'shared_average_deeptamsa': shared_pair.get('average_deeptamsa'),
|
||||
'shared_residual': shared_pair.get('residual'),
|
||||
})
|
||||
|
||||
for pair in ithasala_pairs:
|
||||
|
||||
+46
-18
@@ -27,6 +27,41 @@ def _nearest_aspect(delta: float) -> tuple[float, float]:
|
||||
return min(candidates, key=lambda item: abs(item[1]))
|
||||
|
||||
|
||||
def evaluate_tajika_pair(
|
||||
left: str,
|
||||
right: str,
|
||||
planets: dict[str, dict[str, Any]],
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return the shared motion/orb contract for one seven-planet Tajika pair."""
|
||||
if left not in planets or right not in planets:
|
||||
return None
|
||||
left_payload = planets[left]
|
||||
right_payload = planets[right]
|
||||
if not isinstance(left_payload, dict) or not isinstance(right_payload, dict):
|
||||
return None
|
||||
if "longitude" not in left_payload or "longitude" not in right_payload:
|
||||
return None
|
||||
if "speed" not in left_payload or "speed" not in right_payload:
|
||||
return None
|
||||
|
||||
left_lon, right_lon = float(left_payload["longitude"]) % 360, float(right_payload["longitude"]) % 360
|
||||
aspect, residual = _nearest_aspect(right_lon - left_lon)
|
||||
orb = (DEEPTAMSA[left] + DEEPTAMSA[right]) / 2.0
|
||||
if abs(residual) > orb:
|
||||
return None
|
||||
relative_speed = float(right_payload["speed"]) - float(left_payload["speed"])
|
||||
future_residual = _signed_angle(residual + relative_speed)
|
||||
applying = abs(future_residual) < abs(residual)
|
||||
return {
|
||||
"planets": [left, right],
|
||||
"aspect": abs(aspect),
|
||||
"residual": round(residual, 6),
|
||||
"average_deeptamsa": orb,
|
||||
"motion": "applying" if applying else "separating",
|
||||
"within_deeptamsa": True,
|
||||
}
|
||||
|
||||
|
||||
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:
|
||||
@@ -40,22 +75,9 @@ def calculate_tajika_interactions(planets: dict[str, dict[str, Any]]) -> dict[st
|
||||
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,
|
||||
})
|
||||
pair = evaluate_tajika_pair(left, right, planets)
|
||||
if pair is not None:
|
||||
interactions.append(pair)
|
||||
return {
|
||||
"scope": "tajika_seven_planet_kernel",
|
||||
"status": "partial",
|
||||
@@ -74,6 +96,12 @@ def calculate_tajika_interactions(planets: dict[str, dict[str, Any]]) -> dict[st
|
||||
}
|
||||
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.",
|
||||
"supported_named_yogas": ["Ithasala", "Easarapha"],
|
||||
"blocked_named_yogas": ["Nakta", "Yamaya", "Manahoo", "Kamboola"],
|
||||
"authority_lane": "scripts/tajika_named_yoga_authority.py",
|
||||
"boundary": (
|
||||
"Seven-planet aspect, Deeptamsa, and applying/separating evidence are "
|
||||
"admissible for governed Ithasala/Easarapha authority. Broader named-yoga "
|
||||
"chains and event verdicts remain blocked pending classic golden cases."
|
||||
),
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -54,6 +55,24 @@ try:
|
||||
except Exception: # pragma: no cover - import path varies in tests/CLI
|
||||
from scripts.cross_system_arbitrator import build_cross_system_arbitration
|
||||
|
||||
try:
|
||||
from specialized_indian_closure_review import build_specialized_indian_closure_review
|
||||
except Exception: # pragma: no cover - research helper is not vendored here
|
||||
try:
|
||||
from scripts.specialized_indian_closure_review import build_specialized_indian_closure_review
|
||||
except Exception:
|
||||
def build_specialized_indian_closure_review(**kwargs):
|
||||
return {"status": "blocked", "reason": "specialized_indian_closure_review_absent"}
|
||||
|
||||
try:
|
||||
from finance_astrology_support_review import build_finance_astrology_support_review
|
||||
except Exception: # pragma: no cover - research helper is not vendored here
|
||||
try:
|
||||
from scripts.finance_astrology_support_review import build_finance_astrology_support_review
|
||||
except Exception:
|
||||
def build_finance_astrology_support_review(**kwargs):
|
||||
return {"status": "blocked", "reason": "finance_astrology_support_review_absent"}
|
||||
|
||||
try:
|
||||
from functional_benefics import derive_functional_benefic_malefic
|
||||
except Exception: # pragma: no cover - import path varies in tests/CLI
|
||||
@@ -63,6 +82,22 @@ try:
|
||||
from real_case_replay_validator import validate_manifest as validate_real_case_replay_manifest
|
||||
except Exception: # pragma: no cover - import path varies in tests/CLI
|
||||
from scripts.real_case_replay_validator import validate_manifest as validate_real_case_replay_manifest
|
||||
_EMPTY_METHODOLOGY_ROLES = {
|
||||
"core": [],
|
||||
"enhancement": [],
|
||||
"adjudication": [],
|
||||
"annual_trigger": [],
|
||||
"blocked": [],
|
||||
}
|
||||
_DEFAULT_AUTHORITY_ORDER = ["core", "enhancement", "adjudication", "annual_trigger", "blocked"]
|
||||
_DOCUMENT_ROUTE_BY_CANONICAL = {
|
||||
"career": "career-timing-strict",
|
||||
"marriage": "relationship-timing-strict",
|
||||
"wealth": "finance-timing-strict",
|
||||
"health": "health-timing-strict",
|
||||
"timing": "event-timing-strict",
|
||||
"general": "full-reading-strict",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -71,6 +106,8 @@ class RouteDefinition:
|
||||
primary_theme: str
|
||||
focus_techniques: list[str]
|
||||
display_label: str
|
||||
methodology_roles: dict[str, list[str]] = field(default_factory=lambda: dict(_EMPTY_METHODOLOGY_ROLES))
|
||||
authority_order: list[str] = field(default_factory=lambda: list(_DEFAULT_AUTHORITY_ORDER))
|
||||
|
||||
|
||||
class UnifiedConsultationOrchestrator:
|
||||
@@ -95,12 +132,19 @@ class UnifiedConsultationOrchestrator:
|
||||
"D10",
|
||||
"D2",
|
||||
"D4",
|
||||
"D6",
|
||||
"D8",
|
||||
"D30",
|
||||
"planet_degrees",
|
||||
"house_degrees",
|
||||
"dasha_boundaries",
|
||||
"narayana_dasha",
|
||||
"shadbala",
|
||||
"ashtakavarga",
|
||||
"yogas",
|
||||
"functional_benefic_malefic",
|
||||
"transit",
|
||||
"non_medical_boundary",
|
||||
"UL",
|
||||
"A7",
|
||||
"A10",
|
||||
@@ -118,6 +162,14 @@ class UnifiedConsultationOrchestrator:
|
||||
primary_theme="career",
|
||||
focus_techniques=["D10", "Dasha", "Shadbala", "Transit", "Narayana Dasha"],
|
||||
display_label="career",
|
||||
methodology_roles={
|
||||
"core": ["D10", "Dasha", "Shadbala", "Transit", "Narayana Dasha"],
|
||||
"enhancement": ["A10"],
|
||||
"adjudication": [],
|
||||
"annual_trigger": [],
|
||||
"blocked": [],
|
||||
},
|
||||
authority_order=["core", "enhancement", "adjudication", "annual_trigger", "blocked"],
|
||||
),
|
||||
"marriage": RouteDefinition(
|
||||
question_type="marriage",
|
||||
@@ -134,7 +186,17 @@ class UnifiedConsultationOrchestrator:
|
||||
"health": RouteDefinition(
|
||||
question_type="health",
|
||||
primary_theme="health",
|
||||
focus_techniques=["D1", "D6", "D8", "Dasha", "Shadbala", "non-medical boundary"],
|
||||
focus_techniques=[
|
||||
"D6",
|
||||
"D8",
|
||||
"D30",
|
||||
"Dasha",
|
||||
"Narayana Dasha",
|
||||
"Shadbala",
|
||||
"Transit",
|
||||
"Functional Benefic/Malefic",
|
||||
"non-medical boundary",
|
||||
],
|
||||
display_label="health",
|
||||
),
|
||||
"education": RouteDefinition(
|
||||
@@ -142,6 +204,14 @@ class UnifiedConsultationOrchestrator:
|
||||
primary_theme="education",
|
||||
focus_techniques=["D5", "D24", "5th house", "9th house", "Dasha"],
|
||||
display_label="education",
|
||||
methodology_roles={
|
||||
"core": ["D24", "D10", "Dasha", "Narayana Dasha", "Shadbala", "Transit"],
|
||||
"enhancement": ["A10"],
|
||||
"adjudication": [],
|
||||
"annual_trigger": [],
|
||||
"blocked": [],
|
||||
},
|
||||
authority_order=["core", "enhancement", "adjudication", "annual_trigger", "blocked"],
|
||||
),
|
||||
"migration": RouteDefinition(
|
||||
question_type="migration",
|
||||
@@ -166,12 +236,28 @@ class UnifiedConsultationOrchestrator:
|
||||
primary_theme="timing",
|
||||
focus_techniques=["Dasha", "Transit", "Double Transit", "Gochara"],
|
||||
display_label="timing",
|
||||
methodology_roles={
|
||||
"core": ["Dasha", "Narayana Dasha", "Transit", "Double Transit", "Gochara"],
|
||||
"enhancement": [],
|
||||
"adjudication": [],
|
||||
"annual_trigger": [],
|
||||
"blocked": [],
|
||||
},
|
||||
authority_order=["core", "enhancement", "adjudication", "annual_trigger", "blocked"],
|
||||
),
|
||||
"general": RouteDefinition(
|
||||
question_type="general",
|
||||
primary_theme="general",
|
||||
focus_techniques=["D1", "D9", "Dasha", "Yoga", "Shadbala", "Ashtakavarga"],
|
||||
display_label="general",
|
||||
methodology_roles={
|
||||
"core": ["D1", "D9", "Dasha", "Shadbala", "Ashtakavarga"],
|
||||
"enhancement": ["Yoga"],
|
||||
"adjudication": [],
|
||||
"annual_trigger": [],
|
||||
"blocked": [],
|
||||
},
|
||||
authority_order=["core", "enhancement", "adjudication", "annual_trigger", "blocked"],
|
||||
),
|
||||
}
|
||||
_SYNC_STEPS_BY_ROUTE = {
|
||||
@@ -228,6 +314,42 @@ class UnifiedConsultationOrchestrator:
|
||||
"general": {"D1", "D9"},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def route_surface_contract(question_type: str) -> dict[str, Any]:
|
||||
raw = str(question_type or "").strip().lower()
|
||||
try:
|
||||
runtime_route = normalize_domain(raw or "general")
|
||||
except ValueError:
|
||||
runtime_route = raw or "general"
|
||||
document_route = _DOCUMENT_ROUTE_BY_CANONICAL.get(
|
||||
runtime_route,
|
||||
runtime_route.replace("_", "-"),
|
||||
)
|
||||
registry_route = document_route.replace("-", "_")
|
||||
alias_set = {runtime_route, registry_route, document_route, raw} - {""}
|
||||
if runtime_route == "wealth":
|
||||
alias_set.update({
|
||||
"finance",
|
||||
"money",
|
||||
"wealth-timing-strict",
|
||||
"wealth_timing_strict",
|
||||
"finance-timing-strict",
|
||||
"finance_timing_strict",
|
||||
})
|
||||
elif runtime_route == "marriage":
|
||||
alias_set.update({"relationship", "relationship-timing-strict", "relationship_timing_strict"})
|
||||
elif runtime_route == "timing":
|
||||
alias_set.update({"event-timing-strict", "event_timing_strict"})
|
||||
elif runtime_route == "general":
|
||||
alias_set.update({"full-reading-strict", "full_reading_strict", "comprehensive"})
|
||||
return {
|
||||
"runtime_route": runtime_route,
|
||||
"registry_route": registry_route,
|
||||
"document_route": document_route,
|
||||
"audit_route": runtime_route,
|
||||
"route_aliases": sorted(alias_set),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def route_profile_contract(cls, route_name: str) -> dict[str, Any]:
|
||||
"""Expose reader sections without replacing runtime technique audit."""
|
||||
@@ -246,6 +368,19 @@ class UnifiedConsultationOrchestrator:
|
||||
"execution_boundary": "Profile labels do not replace Technique Audit Table execution status.",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def evidence_packet_required_sections(cls, route_name: str | None = None) -> list[str]:
|
||||
required = list(cls.EVIDENCE_PACKET_REQUIRED_SECTIONS)
|
||||
try:
|
||||
route = normalize_domain(route_name) if route_name else "general"
|
||||
except ValueError:
|
||||
route = route_name or "general"
|
||||
if route == "health":
|
||||
for section in ("D6", "D8", "D30", "narayana_dasha", "functional_benefic_malefic", "transit", "non_medical_boundary"):
|
||||
if section not in required:
|
||||
required.append(section)
|
||||
return required
|
||||
|
||||
def normalize_themes(self, raw: Any) -> list[str]:
|
||||
return normalize_consultation_themes(raw)
|
||||
|
||||
@@ -323,12 +458,14 @@ class UnifiedConsultationOrchestrator:
|
||||
|
||||
@staticmethod
|
||||
def _route_packet(route: RouteDefinition, *, source: str) -> dict[str, Any]:
|
||||
surface = UnifiedConsultationOrchestrator.route_surface_contract(route.question_type)
|
||||
return {
|
||||
"question_type": route.question_type,
|
||||
"primary_theme": route.primary_theme,
|
||||
"focus_techniques": list(route.focus_techniques),
|
||||
"display_label": route.display_label,
|
||||
"route_source": source,
|
||||
**surface,
|
||||
}
|
||||
|
||||
def route_profile(self, question: str, themes: list[str] | None = None) -> dict[str, Any]:
|
||||
@@ -467,6 +604,73 @@ class UnifiedConsultationOrchestrator:
|
||||
},
|
||||
}
|
||||
|
||||
def _build_key_time_nodes(
|
||||
self,
|
||||
route_profile: dict[str, Any],
|
||||
narrative: dict[str, Any],
|
||||
) -> list[dict[str, Any]]:
|
||||
themes = list(route_profile.get("themes") or narrative.keys())
|
||||
nodes: list[dict[str, Any]] = []
|
||||
for theme in themes:
|
||||
item = narrative.get(theme)
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
for section in ("timing_windows", "candidate_windows", "opportunity_windows"):
|
||||
raw_nodes = item.get(section)
|
||||
if not isinstance(raw_nodes, list):
|
||||
continue
|
||||
for raw_node in raw_nodes:
|
||||
normalized = self._normalize_key_time_node(theme, section, raw_node)
|
||||
if normalized:
|
||||
nodes.append(normalized)
|
||||
return nodes[:7]
|
||||
|
||||
@staticmethod
|
||||
def _normalize_key_time_node(theme: str, section: str, raw_node: Any) -> dict[str, Any] | None:
|
||||
if isinstance(raw_node, str):
|
||||
text = raw_node.strip()
|
||||
if not text:
|
||||
return None
|
||||
return {
|
||||
"theme": theme,
|
||||
"section": section,
|
||||
"label": text,
|
||||
"window": text,
|
||||
"status": "parameter_sensitive",
|
||||
}
|
||||
if not isinstance(raw_node, dict):
|
||||
return None
|
||||
label = str(
|
||||
raw_node.get("label")
|
||||
or raw_node.get("title")
|
||||
or raw_node.get("name")
|
||||
or raw_node.get("window")
|
||||
or raw_node.get("date_range")
|
||||
or raw_node.get("date")
|
||||
or ""
|
||||
).strip()
|
||||
window = str(
|
||||
raw_node.get("window")
|
||||
or raw_node.get("date_range")
|
||||
or raw_node.get("date")
|
||||
or raw_node.get("timeframe")
|
||||
or label
|
||||
).strip()
|
||||
if not label and not window:
|
||||
return None
|
||||
node = {
|
||||
"theme": theme,
|
||||
"section": section,
|
||||
"label": label or window,
|
||||
"window": window or label,
|
||||
"status": str(raw_node.get("status") or "parameter_sensitive"),
|
||||
}
|
||||
for key in ("strength", "basis", "trigger_condition", "verification_hint", "source", "priority"):
|
||||
value = raw_node.get(key)
|
||||
if value not in (None, "", [], {}):
|
||||
node[key] = value
|
||||
return node
|
||||
|
||||
def shared_contract(
|
||||
self,
|
||||
*,
|
||||
@@ -590,6 +794,49 @@ class UnifiedConsultationOrchestrator:
|
||||
"source_path": source_path,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _build_professional_support_cross_reference(narrative: dict[str, Any]) -> dict[str, Any]:
|
||||
topics: dict[str, Any] = {}
|
||||
support_statuses: list[str] = []
|
||||
for theme, item in narrative.items():
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
system_views = item.get("system_views") if isinstance(item.get("system_views"), dict) else {}
|
||||
support_topics = sorted(system_views)
|
||||
if system_views:
|
||||
support_statuses.append(str(item.get("status") or "used"))
|
||||
topics[theme] = {
|
||||
"status": str(item.get("status") or "used"),
|
||||
"system_views": system_views,
|
||||
"support_topics": support_topics,
|
||||
"summary": item.get("summary") or item.get("consensus_summary") or "",
|
||||
}
|
||||
return {
|
||||
"status": "used" if support_statuses else "blocked",
|
||||
"topics": topics,
|
||||
"supported_theme_count": len(support_statuses),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _build_restricted_materials_reference(audit_rows: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
blocked = [
|
||||
str(row.get("technique") or row.get("name") or "unknown")
|
||||
for row in audit_rows
|
||||
if str(row.get("report_adoption") or row.get("status") or "").lower() == "blocked"
|
||||
]
|
||||
conditional = [
|
||||
str(row.get("technique") or row.get("name") or "unknown")
|
||||
for row in audit_rows
|
||||
if str(row.get("report_adoption") or "").lower() == "conditional_evidence"
|
||||
]
|
||||
return {
|
||||
"status": "used" if blocked or conditional else "blocked",
|
||||
"blocked_techniques": blocked,
|
||||
"conditional_evidence": conditional,
|
||||
"blocked_count": len(blocked),
|
||||
"conditional_count": len(conditional),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _external_engine_cross_validation(vedastro_state: str) -> dict[str, Any]:
|
||||
repo_root = Path(__file__).resolve().parents[1]
|
||||
@@ -678,6 +925,21 @@ class UnifiedConsultationOrchestrator:
|
||||
jaimini_arudha = modules["jaimini"].get("arudha_padas")
|
||||
arudha_padas = jaimini_arudha if isinstance(jaimini_arudha, dict) else {}
|
||||
pada_map = arudha_padas.get("padas") if isinstance(arudha_padas.get("padas"), dict) else arudha_padas
|
||||
jaimini_packet = (
|
||||
chart_data.get("jaimini")
|
||||
if isinstance(chart_data.get("jaimini"), dict)
|
||||
else modules.get("jaimini") if isinstance(modules.get("jaimini"), dict) else {}
|
||||
)
|
||||
sudarshana_packet = (
|
||||
chart_data.get("sudarshana")
|
||||
if isinstance(chart_data.get("sudarshana"), dict)
|
||||
else modules.get("sudarshana") if isinstance(modules.get("sudarshana"), dict) else {}
|
||||
)
|
||||
sahams_packet = (
|
||||
chart_data.get("sahams")
|
||||
if isinstance(chart_data.get("sahams"), dict)
|
||||
else modules.get("sahams") if isinstance(modules.get("sahams"), dict) else {}
|
||||
)
|
||||
ascendant = base_chart.get("ascendant") if isinstance(base_chart.get("ascendant"), dict) else {}
|
||||
ascendant_sign = ascendant.get("sign") if isinstance(ascendant, dict) else None
|
||||
functional_layer = derive_functional_benefic_malefic(ascendant_sign)
|
||||
@@ -747,6 +1009,9 @@ class UnifiedConsultationOrchestrator:
|
||||
archive_manifest if archive_manifest.get("archive_count") else None,
|
||||
"vedastro_gateway.archives",
|
||||
),
|
||||
"jaimini": self._section(jaimini_packet, "chart.modules.jaimini"),
|
||||
"sudarshana": self._section(sudarshana_packet, "chart.modules.sudarshana"),
|
||||
"sahams": self._section(sahams_packet, "chart.modules.sahams"),
|
||||
}
|
||||
for division in FORMAL_DIVISIONS:
|
||||
if division == 1:
|
||||
@@ -769,7 +1034,9 @@ class UnifiedConsultationOrchestrator:
|
||||
return {
|
||||
"status": "complete" if not missing else "partial",
|
||||
"route": dict(route_packet),
|
||||
"required_sections": list(self.EVIDENCE_PACKET_REQUIRED_SECTIONS),
|
||||
"required_sections": self.evidence_packet_required_sections(
|
||||
str((route_packet or {}).get("question_type") or (route_packet or {}).get("primary_theme") or "general")
|
||||
),
|
||||
"sections": sections,
|
||||
"functional_benefic_malefic": functional_layer,
|
||||
"signals": [item for item in signals if isinstance(item, dict)],
|
||||
@@ -1007,6 +1274,7 @@ class UnifiedConsultationOrchestrator:
|
||||
blind: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
official = vedastro_official if isinstance(vedastro_official, dict) else {}
|
||||
route_name = str(route_packet.get("question_type") or route_packet.get("primary_theme") or "general")
|
||||
runtime_truth = official.get("runtime_truth") if isinstance(official.get("runtime_truth"), dict) else {}
|
||||
vedastro_state = self._vedastro_cloud_state(official)
|
||||
external_cross_validation = self._external_engine_cross_validation(vedastro_state)
|
||||
@@ -1049,6 +1317,19 @@ class UnifiedConsultationOrchestrator:
|
||||
jyotish_evidence=packet,
|
||||
western_evidence=western_evidence_packet,
|
||||
)
|
||||
specialized_indian_closure_review = build_specialized_indian_closure_review(
|
||||
jaimini_packet=packet_sections.get("jaimini"),
|
||||
sudarshana_packet=packet.get("sudarshana") if isinstance(packet.get("sudarshana"), dict) else packet_sections.get("sudarshana"),
|
||||
sahams_packet=packet.get("sahams") if isinstance(packet.get("sahams"), dict) else packet_sections.get("sahams"),
|
||||
)
|
||||
finance_astrology_support_review = build_finance_astrology_support_review(
|
||||
route_packet=route_packet,
|
||||
machine_evidence_packet=packet,
|
||||
runtime_evidence_log={
|
||||
"quality_gate": {"technique_audit_table": []},
|
||||
"cross_system_arbitration": cross_system_arbitration,
|
||||
},
|
||||
)
|
||||
if cross_system_arbitration["status"] != "used":
|
||||
blocked_items.append("cross_system_arbitration_not_complete")
|
||||
technique_audit_table = [
|
||||
@@ -1140,6 +1421,13 @@ class UnifiedConsultationOrchestrator:
|
||||
"vedastro_runtime_truth": dict(runtime_truth),
|
||||
"external_engine_cross_validation": external_cross_validation,
|
||||
"cross_system_arbitration": cross_system_arbitration,
|
||||
"kp_western_support": {
|
||||
"convergence": cross_system_arbitration.get("kp_western_convergence") or {},
|
||||
"negative_evidence": cross_system_arbitration.get("negative_evidence") or {},
|
||||
"real_case_support": cross_system_arbitration.get("western_real_case_support") or {},
|
||||
},
|
||||
"specialized_indian_closure_review": specialized_indian_closure_review,
|
||||
"finance_astrology_support_review": finance_astrology_support_review,
|
||||
"source_priority": {
|
||||
"mode": self.SOURCE_PRIORITY["mode"],
|
||||
"priority": list(self.SOURCE_PRIORITY["priority"]),
|
||||
@@ -1153,7 +1441,7 @@ class UnifiedConsultationOrchestrator:
|
||||
},
|
||||
"evidence_packet_contract": {
|
||||
"status": packet_status,
|
||||
"required_sections": list(self.EVIDENCE_PACKET_REQUIRED_SECTIONS),
|
||||
"required_sections": self.evidence_packet_required_sections(route_name),
|
||||
"missing_sections": packet.get("missing_sections", []),
|
||||
},
|
||||
"blind_technical_mode": {
|
||||
@@ -1182,14 +1470,275 @@ class UnifiedConsultationOrchestrator:
|
||||
"External Engine Cross-Validation",
|
||||
"Western Cross-Validation",
|
||||
"Cross-System Arbitration",
|
||||
"KP-Western Convergence",
|
||||
"Western Negative Evidence",
|
||||
"Western Real-Case Calibration",
|
||||
"Evidence Packet",
|
||||
"Blind Technical Mode",
|
||||
"MEVG / Global Web Evidence",
|
||||
"Real Case Calibration",
|
||||
"Timing Precision Gate",
|
||||
"Functional Benefic/Malefic",
|
||||
],
|
||||
"Functional Benefic/Malefic",
|
||||
"Specialized Indian Closure Review",
|
||||
"Finance Astrology Support Review",
|
||||
],
|
||||
"status": "blocked" if blocked_items else "pass",
|
||||
"blocked_items": blocked_items,
|
||||
},
|
||||
}
|
||||
|
||||
def build_expert_judgment_shadow_input(
|
||||
self,
|
||||
*,
|
||||
question: str,
|
||||
route_packet: dict[str, Any],
|
||||
route_profile: dict[str, Any] | None = None,
|
||||
machine_evidence_packet: dict[str, Any] | None = None,
|
||||
runtime_evidence_log: dict[str, Any] | None = None,
|
||||
legacy_prediction_payload: dict[str, Any] | None = None,
|
||||
legacy_strict_workflows: dict[str, Any] | None = None,
|
||||
request_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build Phase 1 shadow input without introducing judgment behavior."""
|
||||
try:
|
||||
from scripts.expert_judgment import ExpertJudgmentRequest, assemble_expert_judgment_input
|
||||
from scripts.expert_judgment.adapters import (
|
||||
build_activation_layer,
|
||||
build_legacy_prediction_hint,
|
||||
build_legacy_strict_evidence_items,
|
||||
)
|
||||
except Exception: # pragma: no cover - research helper is not vendored here
|
||||
try:
|
||||
from expert_judgment import ExpertJudgmentRequest, assemble_expert_judgment_input
|
||||
from expert_judgment.adapters import (
|
||||
build_activation_layer,
|
||||
build_legacy_prediction_hint,
|
||||
build_legacy_strict_evidence_items,
|
||||
)
|
||||
except Exception:
|
||||
return {"status": "blocked", "reason": "expert_judgment_module_absent"}
|
||||
|
||||
packet = machine_evidence_packet if isinstance(machine_evidence_packet, dict) else {}
|
||||
sections = packet.get("sections") if isinstance(packet.get("sections"), dict) else {}
|
||||
route = dict(route_packet or {})
|
||||
profile = dict(route_profile or {})
|
||||
runtime_log = runtime_evidence_log if isinstance(runtime_evidence_log, dict) else {}
|
||||
quality_gate = runtime_log.get("quality_gate") if isinstance(runtime_log.get("quality_gate"), dict) else {}
|
||||
question_id = str(request_id or route.get("request_id") or route.get("question_id") or "shadow-request")
|
||||
domain = str(route.get("primary_theme") or route.get("question_type") or "general")
|
||||
question_type = str(route.get("question_type") or domain)
|
||||
mode = str(profile.get("presentation_mode") or "default")
|
||||
request = ExpertJudgmentRequest(
|
||||
schema_version="expert_judgment_request.v1",
|
||||
request_context={
|
||||
"question_id": question_id,
|
||||
"question_text": question or "",
|
||||
"domain": domain,
|
||||
"question_type": question_type,
|
||||
"precision_target": "quarter_window",
|
||||
"mode": mode if mode in {"default", "high_rigor", "research"} else "default",
|
||||
},
|
||||
)
|
||||
audit_payload = {
|
||||
"audit_id": f"{question_id}-audit",
|
||||
"status": self._normalize_shadow_status(quality_gate.get("status") or "blocked"),
|
||||
"required_rows": self._shadow_required_audit_rows(quality_gate),
|
||||
"blocked_items": list(quality_gate.get("blocked_items") or []),
|
||||
"claim_boundaries": [
|
||||
"shadow input only",
|
||||
"no final judgment",
|
||||
"legacy hints stay isolated",
|
||||
],
|
||||
}
|
||||
activation_layer = build_activation_layer(
|
||||
vimshottari=self._shadow_activation_payload(sections.get("dasha_boundaries"), "Vimshottari"),
|
||||
narayana=self._shadow_activation_payload(sections.get("narayana_dasha"), "Narayana"),
|
||||
blocked_sources=self._shadow_activation_blocked_sources(sections),
|
||||
)
|
||||
legacy_strict_items = build_legacy_strict_evidence_items(legacy_strict_workflows)
|
||||
assembled = assemble_expert_judgment_input(
|
||||
request,
|
||||
domain_context={
|
||||
"domain": domain,
|
||||
"question_type": question_type,
|
||||
"themes": list(profile.get("themes") or []),
|
||||
"route_label": route.get("display_label"),
|
||||
},
|
||||
audit_context={
|
||||
"status": audit_payload["status"],
|
||||
"timing_precision_gate": dict(
|
||||
(runtime_log.get("real_case_calibration") or {}).get("timing_precision_gate") or {}
|
||||
),
|
||||
},
|
||||
evidence_graph_ref={"graph_id": f"{question_id}-graph"},
|
||||
activation_layer=activation_layer,
|
||||
legacy_prediction=build_legacy_prediction_hint(legacy_prediction_payload),
|
||||
evidence_items=self._build_shadow_evidence_items(sections) + legacy_strict_items,
|
||||
audit_payload=audit_payload,
|
||||
runtime_log_path="runtime_evidence_log",
|
||||
technique_audit_table_path="runtime_evidence_log.quality_gate.technique_audit_table",
|
||||
required_rows=audit_payload["required_rows"],
|
||||
)
|
||||
created_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
shadow_run_id = f"shadow-input://{question_id}"
|
||||
return {
|
||||
"shadow_run_id": shadow_run_id,
|
||||
"request_id": question_id,
|
||||
"expert_input_ref": f"expert_judgment_input://{question_id}",
|
||||
"audit_snapshot_ref": f"audit_snapshot://{question_id}",
|
||||
"created_at": created_at,
|
||||
"status": "generated",
|
||||
"shadow_snapshot": {
|
||||
"shadow_run_id": shadow_run_id,
|
||||
"request_id": question_id,
|
||||
"expert_input_ref": f"expert_judgment_input://{question_id}",
|
||||
"audit_snapshot_ref": f"audit_snapshot://{question_id}",
|
||||
"created_at": created_at,
|
||||
"status": "generated",
|
||||
},
|
||||
"expert_judgment_input": asdict(assembled),
|
||||
"audit_snapshot": dict(assembled.audit_context),
|
||||
"boundary": "Shadow input only; no verdict, confidence, or final judgment.",
|
||||
}
|
||||
|
||||
def build_shadow_judgment_runtime(
|
||||
self,
|
||||
*,
|
||||
expert_judgment_shadow: dict[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build Phase 2C shadow adjudication artifacts without product promotion."""
|
||||
try:
|
||||
from scripts.expert_judgment import (
|
||||
build_judgment_shadow_output,
|
||||
build_shadow_judgment_artifact,
|
||||
build_shadow_review_packet,
|
||||
build_shadow_stage_archive,
|
||||
)
|
||||
from scripts.expert_judgment.schemas import ExpertJudgmentInput
|
||||
except ModuleNotFoundError: # pragma: no cover - script execution path
|
||||
from expert_judgment import (
|
||||
build_judgment_shadow_output,
|
||||
build_shadow_judgment_artifact,
|
||||
build_shadow_review_packet,
|
||||
build_shadow_stage_archive,
|
||||
)
|
||||
from expert_judgment.schemas import ExpertJudgmentInput
|
||||
|
||||
shadow = expert_judgment_shadow if isinstance(expert_judgment_shadow, dict) else {}
|
||||
input_payload = shadow.get("expert_judgment_input") if isinstance(shadow.get("expert_judgment_input"), dict) else None
|
||||
if not isinstance(input_payload, dict):
|
||||
raise ValueError("expert_judgment_shadow must contain expert_judgment_input")
|
||||
|
||||
expert_input = ExpertJudgmentInput(**input_payload)
|
||||
shadow_output = build_judgment_shadow_output(expert_input)
|
||||
stage_archive = build_shadow_stage_archive(
|
||||
shadow_run_id=str(shadow.get("shadow_run_id") or "shadow-input://unknown"),
|
||||
request_id=str(shadow.get("request_id") or "shadow-request"),
|
||||
shadow_output=shadow_output,
|
||||
audit_snapshot=dict(shadow.get("audit_snapshot") or {}),
|
||||
created_at=str(shadow.get("created_at") or datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")),
|
||||
)
|
||||
shadow_artifact = build_shadow_judgment_artifact(
|
||||
shadow_run_id=stage_archive["shadow_run_id"],
|
||||
request_id=stage_archive["request_id"],
|
||||
shadow_stage_archive=stage_archive,
|
||||
)
|
||||
shadow_review = build_shadow_review_packet(
|
||||
shadow_run_id=stage_archive["shadow_run_id"],
|
||||
request_id=stage_archive["request_id"],
|
||||
expert_judgment_input=input_payload,
|
||||
shadow_stage_archive=stage_archive,
|
||||
shadow_judgment_artifact=shadow_artifact,
|
||||
)
|
||||
return {
|
||||
"shadow_run_id": stage_archive["shadow_run_id"],
|
||||
"request_id": stage_archive["request_id"],
|
||||
"shadow_stage_archive_ref": f"{stage_archive['shadow_run_id']}#stage_archive",
|
||||
"shadow_judgment_artifact_ref": f"{stage_archive['shadow_run_id']}#judgment_artifact",
|
||||
"shadow_review_ref": f"{stage_archive['shadow_run_id']}#review",
|
||||
"status": "generated",
|
||||
"shadow_stage_archive": stage_archive,
|
||||
"shadow_judgment_artifact": shadow_artifact,
|
||||
"shadow_review": shadow_review,
|
||||
"boundary": "Shadow adjudication runtime only; no final adjudication, confidence, or product promotion.",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _normalize_shadow_status(status: Any) -> str:
|
||||
status_text = str(status or "").strip().lower()
|
||||
if status_text in {"used", "complete", "executed", "official_verified", "pass"}:
|
||||
return "executed"
|
||||
if status_text in {"partial", "partial_scored", "local_fallback", "available_not_requested"}:
|
||||
return "partial"
|
||||
if status_text == "parameter_sensitive":
|
||||
return "parameter_sensitive"
|
||||
if status_text == "not_applicable":
|
||||
return "not_applicable"
|
||||
return "blocked"
|
||||
|
||||
def _build_shadow_evidence_items(self, sections: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
items: list[dict[str, Any]] = []
|
||||
for section_name, section_payload in sections.items():
|
||||
if not isinstance(section_payload, dict):
|
||||
continue
|
||||
items.append(
|
||||
{
|
||||
"ref_id": f"shadow-{section_name.lower()}",
|
||||
"source_module": "scripts.unified_consultation_orchestrator",
|
||||
"source_path": str(section_payload.get("source_path") or f"machine_evidence_packet.sections.{section_name}"),
|
||||
"payload_key": f"machine_evidence_packet.sections.{section_name}",
|
||||
"status": self._normalize_shadow_status(section_payload.get("status")),
|
||||
"summary": {"section": section_name},
|
||||
"claim_boundary": "reference only; no final judgment",
|
||||
"evidence_graph_node": f"evidence://shadow/{section_name.lower()}",
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
@staticmethod
|
||||
def _shadow_required_audit_rows(quality_gate: dict[str, Any]) -> list[str]:
|
||||
rows = quality_gate.get("technique_audit_table") if isinstance(quality_gate.get("technique_audit_table"), list) else []
|
||||
required_rows: list[str] = []
|
||||
for row in rows:
|
||||
technique = row.get("technique") if isinstance(row, dict) else None
|
||||
if isinstance(technique, str) and technique:
|
||||
required_rows.append(technique)
|
||||
return required_rows or ["Evidence Packet", "Real Case Calibration", "Timing Precision Gate"]
|
||||
|
||||
def _shadow_activation_payload(
|
||||
self,
|
||||
section_payload: dict[str, Any] | None,
|
||||
system_name: str,
|
||||
) -> dict[str, Any] | None:
|
||||
if not isinstance(section_payload, dict):
|
||||
return None
|
||||
status = self._normalize_shadow_status(section_payload.get("status"))
|
||||
if status == "blocked":
|
||||
return None
|
||||
return {
|
||||
"status": status,
|
||||
"source_path": section_payload.get("source_path"),
|
||||
"system": system_name,
|
||||
}
|
||||
|
||||
def _shadow_activation_blocked_sources(self, sections: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
blocked_sources: list[dict[str, Any]] = []
|
||||
for section_name, system_name in (
|
||||
("dasha_boundaries", "Vimshottari"),
|
||||
("narayana_dasha", "Narayana"),
|
||||
):
|
||||
section_payload = sections.get(section_name)
|
||||
if not isinstance(section_payload, dict):
|
||||
blocked_sources.append(
|
||||
{"name": system_name, "status": "blocked", "reason": "missing_section"}
|
||||
)
|
||||
continue
|
||||
if self._normalize_shadow_status(section_payload.get("status")) == "blocked":
|
||||
blocked_sources.append(
|
||||
{
|
||||
"name": system_name,
|
||||
"status": "blocked",
|
||||
"reason": str(section_payload.get("source_path") or "blocked_source"),
|
||||
}
|
||||
)
|
||||
return blocked_sources
|
||||
|
||||
+52
-2
@@ -34,6 +34,7 @@ VARGA_META = {
|
||||
81:{'name':'Navamsa-Navamsa','cn':'D9之D9','area':'配偶灵性精微层'},
|
||||
108:{'name':'Dwadasamsa-Navamsa','cn':'D12之D9','area':'祖先父母精微层'},
|
||||
144:{'name':'Dwadasamsa-Dwadasamsa','cn':'D12之D12','area':'父母祖先精微层'}}
|
||||
VEDASTRO_RAW_VERIFIED_DIVISIONS = frozenset({2, 3, 4, 7, 9, 10, 12, 16, 20, 24, 27, 40, 45, 60})
|
||||
|
||||
def _si(lon): return int(lon/30)%12
|
||||
def _sn(i): return SIGNS[i%12]
|
||||
@@ -182,6 +183,47 @@ def calc_varga(lon, div, mode='classical_local'):
|
||||
return r
|
||||
|
||||
|
||||
def normalize_varga_ascendant(value):
|
||||
"""Return the public ascendant object used by all varga charts."""
|
||||
source_format = None
|
||||
if isinstance(value, list):
|
||||
candidates = [
|
||||
row for row in value
|
||||
if isinstance(row, dict)
|
||||
and any(
|
||||
str(row.get(field, '')).strip().lower() == 'ascendant'
|
||||
for field in ('name', 'planet', 'key')
|
||||
)
|
||||
]
|
||||
if len(candidates) != 1:
|
||||
raise ValueError('ambiguous legacy ascendant list')
|
||||
value = candidates[0]
|
||||
source_format = 'legacy_list_normalized'
|
||||
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError('ascendant must be a mapping or identified legacy list')
|
||||
|
||||
sign = value.get('sign')
|
||||
if sign not in SIGNS:
|
||||
raise ValueError('ascendant sign is required')
|
||||
sign_idx = value.get('sign_idx', SIGNS.index(sign))
|
||||
if not isinstance(sign_idx, int) or not 0 <= sign_idx < len(SIGNS):
|
||||
raise ValueError('ascendant sign_idx is invalid')
|
||||
degree_in_sign = value.get('degree_in_sign')
|
||||
if not isinstance(degree_in_sign, (int, float)) or not 0 <= degree_in_sign < 30:
|
||||
raise ValueError('ascendant degree_in_sign is invalid')
|
||||
|
||||
normalized = {
|
||||
'sign': sign,
|
||||
'sign_idx': sign_idx,
|
||||
'degree_in_sign': round(float(degree_in_sign), 4),
|
||||
'longitude': round(float(value.get('longitude', sign_idx * 30 + degree_in_sign)) % 360, 4),
|
||||
}
|
||||
if source_format:
|
||||
normalized['source_format'] = source_format
|
||||
return normalized
|
||||
|
||||
|
||||
def calc_64th_navamsa(moon_lon: float) -> Dict:
|
||||
"""Compute the 64th Navamsa from the Moon's Navamsa position.
|
||||
|
||||
@@ -272,9 +314,17 @@ def calc_all_vargas(planet_lons, asc_lon, divisions=None, mode='classical_local'
|
||||
for div in divisions:
|
||||
m=VARGA_META.get(div,{})
|
||||
key=f"D{div}_{m.get('name',f'D{div}')}"
|
||||
mapping_status = (
|
||||
'official_raw_sign_verified'
|
||||
if mode == 'vedastro' and div in VEDASTRO_RAW_VERIFIED_DIVISIONS
|
||||
else 'legacy_candidate_unverified'
|
||||
if mode == 'vedastro'
|
||||
else 'classical_local'
|
||||
)
|
||||
vd={'_meta':{'div':div,'name':m.get('name',''),'cn':m.get('cn',''),
|
||||
'area':m.get('area',''),'part_size':30.0/div}}
|
||||
vd['Ascendant']=calc_varga(asc_lon,div,mode=mode)
|
||||
'area':m.get('area',''),'part_size':30.0/div,
|
||||
'mapping_status': mapping_status}}
|
||||
vd['Ascendant']=normalize_varga_ascendant(calc_varga(asc_lon,div,mode=mode))
|
||||
for pn,lon in planet_lons.items():
|
||||
vd[pn]=calc_varga(lon,div,mode=mode)
|
||||
# 尊贵状态
|
||||
|
||||
@@ -10,9 +10,20 @@ from zoneinfo import ZoneInfo
|
||||
import swisseph as swe
|
||||
|
||||
try:
|
||||
from western_chart_engine import _ASPECTS, _PLANETS, _birth_zone, _longitude, _orb_for, _point, build_tropical_natal_chart
|
||||
from western_chart_engine import _ASPECTS, _PLANETS, _RULERS, _SIGNS, _birth_zone, _longitude, _orb_for, _point, build_tropical_natal_chart
|
||||
except ImportError: # pragma: no cover - package import path
|
||||
from scripts.western_chart_engine import _ASPECTS, _PLANETS, _birth_zone, _longitude, _orb_for, _point, build_tropical_natal_chart
|
||||
from scripts.western_chart_engine import _ASPECTS, _PLANETS, _RULERS, _SIGNS, _birth_zone, _longitude, _orb_for, _point, build_tropical_natal_chart
|
||||
|
||||
|
||||
_PLANETARY_YEARS = {
|
||||
"sun": 19,
|
||||
"moon": 25,
|
||||
"mercury": 20,
|
||||
"venus": 8,
|
||||
"mars": 15,
|
||||
"jupiter": 12,
|
||||
"saturn": 30,
|
||||
}
|
||||
|
||||
|
||||
def _target_jd(target_date: str, timezone: str | float | int) -> tuple[float, datetime]:
|
||||
@@ -183,6 +194,34 @@ def calculate_secondary_progressions(*, target_date: str, **birth: Any) -> dict[
|
||||
}
|
||||
|
||||
|
||||
def calculate_tertiary_progressed_moon(*, target_date: str, **birth: Any) -> dict[str, Any]:
|
||||
"""Calculate the tertiary progressed Moon using one day per sidereal lunar month."""
|
||||
natal_chart = build_tropical_natal_chart(**birth)
|
||||
target_jd, local = _target_jd(target_date, birth["timezone"])
|
||||
birth_jd = _birth_jd(**birth)
|
||||
elapsed_months = (target_jd - birth_jd) / 27.321661
|
||||
progressed_jd = birth_jd + elapsed_months
|
||||
planets = _progressed_planets(progressed_jd)
|
||||
natal_points = {
|
||||
**natal_chart["natal"]["planets"],
|
||||
"ascendant": natal_chart["natal"]["angles"]["ascendant"],
|
||||
"mc": natal_chart["natal"]["angles"]["mc"],
|
||||
}
|
||||
return {
|
||||
"technique": "tertiary_progressed_moon",
|
||||
"status": "partial",
|
||||
"method": "one_ephemeris_day_per_sidereal_lunar_month",
|
||||
"target_date": target_date,
|
||||
"target_local_time": local.isoformat(),
|
||||
"elapsed_sidereal_lunar_months": round(elapsed_months, 8),
|
||||
"progressed_julian_day_ut": round(progressed_jd, 8),
|
||||
"natal_moon_longitude": natal_chart["natal"]["planets"]["moon"]["longitude"],
|
||||
"progressed_moon": planets["moon"],
|
||||
"aspects": _cross_aspects({"moon": planets["moon"]}, natal_points),
|
||||
"boundary": "Tertiary progressed Moon only; progressed house framework, duration, and interpretation remain separate audited layers.",
|
||||
}
|
||||
|
||||
|
||||
def calculate_solar_arc_directions(*, target_date: str, **birth: Any) -> dict[str, Any]:
|
||||
"""Direct natal points by the true arc of the secondary progressed Sun."""
|
||||
natal_chart = build_tropical_natal_chart(**birth)
|
||||
@@ -355,6 +394,24 @@ def calculate_lunar_return(*, start_date: str, **birth: Any) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def calculate_lunar_return_series(*, target_date: str, months: int = 1, **birth: Any) -> dict[str, Any]:
|
||||
"""Return the next exact lunar return from each of the prior calendar-month anchors."""
|
||||
if not 1 <= int(months) <= 12:
|
||||
raise ValueError("months must be between 1 and 12")
|
||||
target = datetime.fromisoformat(target_date[:10])
|
||||
returns = []
|
||||
for offset in range(int(months) - 1, -1, -1):
|
||||
anchor = target - timedelta(days=31 * offset)
|
||||
returns.append(calculate_lunar_return(start_date=anchor.date().isoformat(), **birth))
|
||||
return {
|
||||
"technique": "lunar_return_series",
|
||||
"target_date": target_date,
|
||||
"months": int(months),
|
||||
"returns": returns,
|
||||
"boundary": "Calendar-month anchor series; each item is a native exact lunar return calculation.",
|
||||
}
|
||||
|
||||
|
||||
def calculate_transit_duration_scan(*, start_date: str, end_date: str, max_days: int = 370, **birth: Any) -> dict[str, Any]:
|
||||
"""Scan daily transit-to-natal aspect activity and group consecutive windows."""
|
||||
start = datetime.fromisoformat(start_date)
|
||||
@@ -400,6 +457,20 @@ def calculate_transit_duration_scan(*, start_date: str, end_date: str, max_days:
|
||||
"end_date": final_date,
|
||||
"min_orb": round(row["min_orb"], 6),
|
||||
})
|
||||
windows = sorted(windows, key=lambda row: (row["start_date"], row["min_orb"], row["transit_planet"]))
|
||||
exact_hit_timeline = [
|
||||
{
|
||||
"layer": "transits",
|
||||
"target": row["natal_point"],
|
||||
"aspect": row["aspect"],
|
||||
"window_start": row["start_date"],
|
||||
"exact_date": row["start_date"] if row["start_date"] == row["end_date"] else row["end_date"],
|
||||
"window_end": row["end_date"],
|
||||
"transit_planet": row["transit_planet"],
|
||||
"min_orb": row["min_orb"],
|
||||
}
|
||||
for row in windows
|
||||
]
|
||||
return {
|
||||
"technique": "transit_duration_scan",
|
||||
"status": "used",
|
||||
@@ -408,7 +479,8 @@ def calculate_transit_duration_scan(*, start_date: str, end_date: str, max_days:
|
||||
"end_date": end_date,
|
||||
"days_scanned": days,
|
||||
"daily_hits": daily_hits,
|
||||
"windows": sorted(windows, key=lambda row: (row["start_date"], row["min_orb"], row["transit_planet"])),
|
||||
"windows": windows,
|
||||
"exact_hit_timeline": exact_hit_timeline,
|
||||
"boundary": "Daily scan only; exact ingress/egress times require sub-daily root finding.",
|
||||
}
|
||||
|
||||
@@ -467,19 +539,152 @@ def calculate_parans_status(*, target_date: str | None = None, **birth: Any) ->
|
||||
}
|
||||
|
||||
|
||||
def calculate_annual_profection(*, target_date: str, **birth: Any) -> dict[str, Any]:
|
||||
"""Calculate annual profection house, sign, and yearly ruler from the tropical ascendant."""
|
||||
natal_chart = build_tropical_natal_chart(**birth)
|
||||
target_local = datetime.fromisoformat(target_date[:10])
|
||||
birth_month = int(birth["month"])
|
||||
birth_day = int(birth["day"])
|
||||
years_elapsed = target_local.year - int(birth["year"])
|
||||
if (target_local.month, target_local.day) < (birth_month, birth_day):
|
||||
years_elapsed -= 1
|
||||
asc_sign = natal_chart["natal"]["ascendant"]["sign"]
|
||||
asc_index = _SIGNS.index(asc_sign)
|
||||
profected_house = years_elapsed % 12 + 1
|
||||
profected_sign = _SIGNS[(asc_index + years_elapsed) % 12]
|
||||
year_lord = _RULERS[profected_sign]
|
||||
year_lord_natal = natal_chart["natal"]["planets"][year_lord]
|
||||
return {
|
||||
"technique": "annual_profection",
|
||||
"status": "used",
|
||||
"target_date": target_date,
|
||||
"years_elapsed": years_elapsed,
|
||||
"natal_ascendant_sign": asc_sign,
|
||||
"profected_house": profected_house,
|
||||
"profected_sign": profected_sign,
|
||||
"year_lord": year_lord,
|
||||
"year_lord_natal_house": year_lord_natal.get("house"),
|
||||
"year_lord_natal_sign": year_lord_natal.get("sign"),
|
||||
"boundary": "Annual profection house/sign activation only; interpretation and event adjudication remain separate audited layers.",
|
||||
}
|
||||
|
||||
|
||||
def _lot_longitude(*, ascendant: float, sun: float, moon: float, is_day_chart: bool, lot: str) -> float:
|
||||
lot_key = lot.lower()
|
||||
if lot_key == "fortune":
|
||||
raw = ascendant + (moon - sun) if is_day_chart else ascendant + (sun - moon)
|
||||
elif lot_key == "spirit":
|
||||
raw = ascendant + (sun - moon) if is_day_chart else ascendant + (moon - sun)
|
||||
else:
|
||||
raise ValueError("lot must be spirit or fortune")
|
||||
return _longitude(raw)
|
||||
|
||||
|
||||
def _period_years_for_sign(sign: str) -> int:
|
||||
return _PLANETARY_YEARS[_RULERS[sign]]
|
||||
|
||||
|
||||
def _build_release_periods(start_sign_index: int, start_date: datetime, levels: int) -> list[dict[str, Any]]:
|
||||
periods: list[dict[str, Any]] = []
|
||||
current_start = start_date
|
||||
for offset in range(12):
|
||||
sign = _SIGNS[(start_sign_index + offset) % 12]
|
||||
years = _period_years_for_sign(sign)
|
||||
current_end = current_start + timedelta(days=round(years * 365.242189))
|
||||
period = {
|
||||
"level": 1,
|
||||
"sign": sign,
|
||||
"years": years,
|
||||
"start_date": current_start.date().isoformat(),
|
||||
"end_date": (current_end - timedelta(days=1)).date().isoformat(),
|
||||
}
|
||||
if levels >= 2:
|
||||
subperiods = []
|
||||
sub_start = current_start
|
||||
for sub_offset in range(12):
|
||||
sub_sign = _SIGNS[(start_sign_index + offset + sub_offset) % 12]
|
||||
sub_years = years * _period_years_for_sign(sub_sign) / 12.0
|
||||
sub_end = sub_start + timedelta(days=round(sub_years * 365.242189))
|
||||
subperiods.append({
|
||||
"level": 2,
|
||||
"sign": sub_sign,
|
||||
"years": round(sub_years, 6),
|
||||
"start_date": sub_start.date().isoformat(),
|
||||
"end_date": (sub_end - timedelta(days=1)).date().isoformat(),
|
||||
})
|
||||
sub_start = sub_end
|
||||
period["subperiods"] = subperiods
|
||||
periods.append(period)
|
||||
current_start = current_end
|
||||
return periods
|
||||
|
||||
|
||||
def calculate_zodiacal_release(*, target_date: str, lot: str = "spirit", levels: int = 2, **birth: Any) -> dict[str, Any]:
|
||||
"""Calculate a bounded L1/L2 zodiacal-release timeline from Fortune or Spirit."""
|
||||
if levels not in {1, 2}:
|
||||
raise ValueError("levels must be 1 or 2")
|
||||
natal_chart = build_tropical_natal_chart(**birth)
|
||||
natal = natal_chart["natal"]
|
||||
ascendant = natal["ascendant"]["longitude"]
|
||||
sun = natal["planets"]["sun"]["longitude"]
|
||||
moon = natal["planets"]["moon"]["longitude"]
|
||||
is_day_chart = natal["planets"]["sun"]["house"] in {7, 8, 9, 10, 11, 12}
|
||||
lot_longitude = _lot_longitude(
|
||||
ascendant=ascendant,
|
||||
sun=sun,
|
||||
moon=moon,
|
||||
is_day_chart=is_day_chart,
|
||||
lot=lot,
|
||||
)
|
||||
lot_sign = _SIGNS[int(lot_longitude // 30)]
|
||||
start_date = datetime(
|
||||
int(birth["year"]),
|
||||
int(birth["month"]),
|
||||
int(birth["day"]),
|
||||
)
|
||||
periods = _build_release_periods(_SIGNS.index(lot_sign), start_date, levels)
|
||||
active_period = next(
|
||||
(
|
||||
period
|
||||
for period in periods
|
||||
if period["start_date"] <= target_date <= period["end_date"]
|
||||
),
|
||||
None,
|
||||
)
|
||||
return {
|
||||
"technique": "zodiacal_release",
|
||||
"status": "partial",
|
||||
"target_date": target_date,
|
||||
"lot": lot.lower(),
|
||||
"lot_longitude": round(lot_longitude, 6),
|
||||
"lot_sign": lot_sign,
|
||||
"day_night_basis": "day_chart" if is_day_chart else "night_chart",
|
||||
"levels": levels,
|
||||
"periods": periods,
|
||||
"active_period": active_period,
|
||||
"boundary": "L1/L2 sign-period release scaffold from the Lot of Spirit/Fortune only; loosing-of-the-bond, peak periods, angularity weighting, and interpretation remain separate audited layers.",
|
||||
}
|
||||
|
||||
|
||||
def build_timing_techniques(
|
||||
*,
|
||||
transit_date: str | None = None,
|
||||
solar_return_year: int | None = None,
|
||||
secondary_progression_date: str | None = None,
|
||||
tertiary_progressed_moon_date: str | None = None,
|
||||
solar_arc_date: str | None = None,
|
||||
converse_secondary_progression_date: str | None = None,
|
||||
converse_solar_arc_date: str | None = None,
|
||||
midpoint_date: str | None = None,
|
||||
lunar_return_start_date: str | None = None,
|
||||
lunar_return_months: int = 1,
|
||||
duration_scan_start_date: str | None = None,
|
||||
duration_scan_end_date: str | None = None,
|
||||
parans_date: str | None = None,
|
||||
profection_date: str | None = None,
|
||||
zodiacal_release_date: str | None = None,
|
||||
zodiacal_release_lot: str = "spirit",
|
||||
zodiacal_release_levels: int = 2,
|
||||
**birth: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Materialize only the requested, independently auditable timing layers."""
|
||||
@@ -492,6 +697,10 @@ def build_timing_techniques(
|
||||
techniques["secondary_progressions"] = calculate_secondary_progressions(
|
||||
target_date=secondary_progression_date, **birth
|
||||
)
|
||||
if tertiary_progressed_moon_date:
|
||||
techniques["tertiary_progressed_moon"] = calculate_tertiary_progressed_moon(
|
||||
target_date=tertiary_progressed_moon_date, **birth
|
||||
)
|
||||
if solar_arc_date:
|
||||
techniques["solar_arc_directions"] = calculate_solar_arc_directions(target_date=solar_arc_date, **birth)
|
||||
if converse_secondary_progression_date:
|
||||
@@ -505,7 +714,11 @@ def build_timing_techniques(
|
||||
if midpoint_date:
|
||||
techniques["midpoints"] = calculate_midpoints(target_date=midpoint_date, **birth)
|
||||
if lunar_return_start_date:
|
||||
techniques["lunar_return"] = calculate_lunar_return(start_date=lunar_return_start_date, **birth)
|
||||
techniques["lunar_return"] = calculate_lunar_return_series(
|
||||
target_date=lunar_return_start_date,
|
||||
months=int(lunar_return_months),
|
||||
**birth,
|
||||
)
|
||||
if duration_scan_start_date and duration_scan_end_date:
|
||||
techniques["transit_duration_scan"] = calculate_transit_duration_scan(
|
||||
start_date=duration_scan_start_date,
|
||||
@@ -514,4 +727,13 @@ def build_timing_techniques(
|
||||
)
|
||||
if parans_date:
|
||||
techniques["parans"] = calculate_parans_status(target_date=parans_date, **birth)
|
||||
if profection_date:
|
||||
techniques["annual_profection"] = calculate_annual_profection(target_date=profection_date, **birth)
|
||||
if zodiacal_release_date:
|
||||
techniques["zodiacal_release"] = calculate_zodiacal_release(
|
||||
target_date=zodiacal_release_date,
|
||||
lot=zodiacal_release_lot,
|
||||
levels=int(zodiacal_release_levels),
|
||||
**birth,
|
||||
)
|
||||
return techniques
|
||||
|
||||
Reference in New Issue
Block a user