feat(upstream): merge a6f47abd engine, MCP, and orchestrator
Independent Staging Quality Gate / validate (push) Has been cancelled
Independent Staging Quality Gate / publish (push) Has been cancelled

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:
Jesse_Chen
2026-09-03 20:28:57 +08:00
co-authored by Cursor
parent 45d132588f
commit f224146348
30 changed files with 17151 additions and 248 deletions
+275 -12
View File
@@ -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
# ---------------------------------------------------------------------------
+90
View File
@@ -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}"
+6
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+328 -7
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+572
View File
@@ -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:
+33
View File
@@ -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
+254
View File
@@ -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]
+13
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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."
),
}
+555 -6
View File
@@ -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
View File
@@ -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)
# 尊贵状态
+226 -4
View File
@@ -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