fix(relationship): freeze marriage event classes and add conditional dashas (BUG-608)
Venus/Jupiter/Moon periods no longer collapse into one marriage-opportunity line. Full reading reports the ten BPHS conditional dasha families; Skill 6.9.16. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Source-bounded Chaturshitisama (84-year) Mahadasha producer.
|
||||
|
||||
Chaturshitisama is conditional: BPHS-style sources apply it when the 10th
|
||||
lord occupies the 10th house. This module requires the caller to provide the
|
||||
10th-lord sign explicitly so it does not guess house/lord rules from a partial
|
||||
chart context.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
|
||||
YEAR_DAYS = 365.25636
|
||||
NAKSHATRA_SPAN = 360.0 / 27.0
|
||||
SWATI_INDEX = 14
|
||||
TOTAL_CYCLE = 84
|
||||
DASHA_SEQUENCE = (
|
||||
("Sun", 12),
|
||||
("Moon", 12),
|
||||
("Mars", 12),
|
||||
("Mercury", 12),
|
||||
("Jupiter", 12),
|
||||
("Venus", 12),
|
||||
("Saturn", 12),
|
||||
)
|
||||
_YEARS = dict(DASHA_SEQUENCE)
|
||||
|
||||
|
||||
def _sign_index(longitude: float) -> int:
|
||||
return int(float(longitude) % 360.0 // 30.0)
|
||||
|
||||
|
||||
def _tenth_house_sign_index(ascendant_longitude: float) -> int:
|
||||
return (_sign_index(ascendant_longitude) + 9) % 12
|
||||
|
||||
|
||||
def chaturshitisama_applicability(
|
||||
*, ascendant_longitude: float, tenth_lord_sign_index: int
|
||||
) -> dict[str, Any]:
|
||||
"""Apply the 10th-lord-in-10th Chaturshitisama condition."""
|
||||
ascendant_sign = _sign_index(ascendant_longitude)
|
||||
tenth_house_sign = _tenth_house_sign_index(ascendant_longitude)
|
||||
lord_sign = int(tenth_lord_sign_index) % 12
|
||||
applicable = lord_sign == tenth_house_sign
|
||||
return {
|
||||
"applicable": applicable,
|
||||
"ascendant_sign_index": ascendant_sign,
|
||||
"tenth_house_sign_index": tenth_house_sign,
|
||||
"tenth_lord_sign_index": lord_sign,
|
||||
"rule_family": "bphs_chaturshitisama_tenth_lord_in_tenth",
|
||||
"reason": (
|
||||
"10th lord is placed in the 10th house."
|
||||
if applicable
|
||||
else "Chaturshitisama requires the 10th lord to occupy the 10th house."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _sequence_from(lord: str) -> list[tuple[str, int]]:
|
||||
start = next(index for index, (item, _years) in enumerate(DASHA_SEQUENCE) if item == lord)
|
||||
return list(DASHA_SEQUENCE[start:] + DASHA_SEQUENCE[:start])
|
||||
|
||||
|
||||
def _birth_lord_and_balance(moon_longitude: float) -> tuple[str, float, int]:
|
||||
moon = float(moon_longitude) % 360.0
|
||||
nakshatra = int(moon // NAKSHATRA_SPAN)
|
||||
lord = DASHA_SEQUENCE[((nakshatra - SWATI_INDEX) % 27) % len(DASHA_SEQUENCE)][0]
|
||||
balance = _YEARS[lord] * (1.0 - (moon % NAKSHATRA_SPAN) / NAKSHATRA_SPAN)
|
||||
return lord, balance, nakshatra
|
||||
|
||||
|
||||
def calculate_chaturshitisama_dasha(birth_info: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return birth-forward MD rows; child-period boundaries stay blocked."""
|
||||
required = ("birth_datetime", "ascendant_longitude", "tenth_lord_sign_index", "moon_longitude")
|
||||
missing = [field for field in required if birth_info.get(field) is None]
|
||||
if missing:
|
||||
return {
|
||||
"applicable": False,
|
||||
"execution_status": "blocked",
|
||||
"confidence_status": "blocked",
|
||||
"reason": "Missing required input: " + ", ".join(missing),
|
||||
"major": [],
|
||||
}
|
||||
birth = birth_info["birth_datetime"]
|
||||
if isinstance(birth, str):
|
||||
birth = datetime.fromisoformat(birth)
|
||||
applicability = chaturshitisama_applicability(
|
||||
ascendant_longitude=float(birth_info["ascendant_longitude"]),
|
||||
tenth_lord_sign_index=int(birth_info["tenth_lord_sign_index"]),
|
||||
)
|
||||
if not applicability["applicable"]:
|
||||
if birth_info.get("standard_table_profile") == "pl9_reports_all_dasha_tables_v1":
|
||||
applicability = {
|
||||
**applicability,
|
||||
"classical_applicable": False,
|
||||
"applicable": True,
|
||||
"standard_table_profile": "pl9_reports_all_dasha_tables_v1",
|
||||
"applicability_reason": applicability["reason"],
|
||||
"reason": (
|
||||
"PL9 standard-table profile requested: generate the report table while preserving "
|
||||
"the unmet classical applicability gate in the audit fields."
|
||||
),
|
||||
}
|
||||
else:
|
||||
return {
|
||||
**applicability,
|
||||
"execution_status": "not_applicable",
|
||||
"confidence_status": "not_applicable",
|
||||
"total_cycle": TOTAL_CYCLE,
|
||||
"major": [],
|
||||
}
|
||||
year_days = float(birth_info.get("dasha_year_days") or YEAR_DAYS)
|
||||
lord, balance, nakshatra = _birth_lord_and_balance(float(birth_info["moon_longitude"]))
|
||||
cursor = birth
|
||||
rows = []
|
||||
for index, (row_lord, full_years) in enumerate(_sequence_from(lord)):
|
||||
years = balance if index == 0 else float(full_years)
|
||||
end = cursor + timedelta(days=years * year_days)
|
||||
rows.append(
|
||||
{
|
||||
"level": "mahadasha",
|
||||
"lord": row_lord,
|
||||
"planet": row_lord,
|
||||
"years": years,
|
||||
"full_years": full_years,
|
||||
"is_birth_balance": index == 0,
|
||||
"start_date": cursor.isoformat(timespec="seconds"),
|
||||
"end_date": end.isoformat(timespec="seconds"),
|
||||
}
|
||||
)
|
||||
cursor = end
|
||||
return {
|
||||
**applicability,
|
||||
"execution_status": "executed",
|
||||
"confidence_status": "parameter_sensitive",
|
||||
"verification_status": "source_bounded_local_candidate",
|
||||
"period_depth_status": "mahadasha_executed_ad_pd_blocked_pending_row_level_replay",
|
||||
"total_cycle": TOTAL_CYCLE,
|
||||
"dasha_year_days": year_days,
|
||||
"moon_nakshatra_index": nakshatra,
|
||||
"starting_lord": lord,
|
||||
"dasha_balance_at_birth_years": balance,
|
||||
"major": rows,
|
||||
"reason": "Local source-bounded MD calculation; AD/PD are intentionally blocked pending row-level parity and runtime wiring.",
|
||||
"source_locator": "BPHS conditional Dasha passages: 10th-lord-in-10th gate, Swati count, Sun-to-Saturn order, and 12-year allotments.",
|
||||
}
|
||||
@@ -29,6 +29,17 @@ TECHNIQUE_TRUTH_IDS = (
|
||||
"conception_chart",
|
||||
"relationship_combinations",
|
||||
"ashtottari_dasha",
|
||||
"punarphoo",
|
||||
"tribhagi",
|
||||
"tribhagi_40",
|
||||
"shodashottari",
|
||||
"dwadashottari",
|
||||
"dwisaptatisama",
|
||||
"shattrimshatsama",
|
||||
"panchottari",
|
||||
"satabdika",
|
||||
"chaturshitisama",
|
||||
"shastihayani",
|
||||
"classical_astrology_core",
|
||||
"classical_event_chart",
|
||||
"classical_real_case_calibration",
|
||||
|
||||
@@ -472,25 +472,45 @@ def predict_career_events(five_levels: Dict) -> Dict:
|
||||
|
||||
|
||||
def predict_relationship_events(five_levels: Dict) -> Dict:
|
||||
"""预测关系事件"""
|
||||
"""预测关系事件(心动 / 成对 / 法律婚分轨)。
|
||||
|
||||
不得把 Venus/Moon/Jupiter 合成一句关系机会。
|
||||
5L / 7H Moon / Punarphoo 走心动轨道;Venus / DK / UL 走法律婚轨道。
|
||||
本函数没有整盘宫主,只能按自然指示星做观察级分轨。
|
||||
"""
|
||||
lord = five_levels['mahadasha']['lord']
|
||||
bhukti_lord = five_levels['bhukti']['lord']
|
||||
|
||||
# Venus、Moon、Jupiter是关系相关行星
|
||||
relationship_planets = ['Venus', 'Moon', 'Jupiter']
|
||||
|
||||
if lord in relationship_planets or bhukti_lord in relationship_planets:
|
||||
return {
|
||||
'events': ['关系发展', '婚姻机会', '合作机会'],
|
||||
'timing': '高',
|
||||
'note': f'{lord}大运 + {bhukti_lord}小运,关系领域活跃'
|
||||
}
|
||||
else:
|
||||
return {
|
||||
'events': ['关系稳定', '内在反思'],
|
||||
'timing': '低',
|
||||
'note': f'{lord}大运,关系领域不是重点'
|
||||
}
|
||||
|
||||
romantic_planets = ['Moon']
|
||||
formation_planets = ['Jupiter']
|
||||
legal_planets = ['Venus']
|
||||
|
||||
event_class = None
|
||||
events = ['关系稳定', '内在反思']
|
||||
timing = '低'
|
||||
if lord in legal_planets or bhukti_lord in legal_planets:
|
||||
event_class = 'legal_marriage'
|
||||
events = ['契约/正式关系候选', '法律婚需 D9+UL 确认']
|
||||
timing = '观察'
|
||||
elif lord in formation_planets or bhukti_lord in formation_planets:
|
||||
event_class = 'relationship_formation'
|
||||
events = ['成对/关系结构候选']
|
||||
timing = '观察'
|
||||
elif lord in romantic_planets or bhukti_lord in romantic_planets:
|
||||
event_class = 'romantic_activation'
|
||||
events = ['心动/类似感情激活']
|
||||
timing = '观察'
|
||||
|
||||
return {
|
||||
'events': events,
|
||||
'timing': timing,
|
||||
'event_class': event_class,
|
||||
'claim_status': 'observation_only',
|
||||
'note': (
|
||||
f'{lord}大运 + {bhukti_lord}小运;事件类={event_class or "unclassified"}。'
|
||||
'不得合并成一句关系机会。Punarphoo/5L 心动不等于法律婚。'
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def predict_wealth_events(five_levels: Dict) -> Dict:
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Source-bounded Dwadashottari (112-year) Mahadasha producer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
|
||||
YEAR_DAYS = 365.25636
|
||||
NAKSHATRA_SPAN = 360.0 / 27.0
|
||||
REVATI_INDEX = 26
|
||||
TOTAL_CYCLE = 112
|
||||
VENUS_SIGNS = {1, 6} # Taurus, Libra, zero-based.
|
||||
DASHA_SEQUENCE = (
|
||||
("Sun", 7),
|
||||
("Jupiter", 9),
|
||||
("Ketu", 11),
|
||||
("Mercury", 13),
|
||||
("Rahu", 15),
|
||||
("Mars", 17),
|
||||
("Saturn", 19),
|
||||
("Moon", 21),
|
||||
)
|
||||
_YEARS = dict(DASHA_SEQUENCE)
|
||||
|
||||
|
||||
def _nakshatra_index(moon_longitude: float) -> int:
|
||||
return int(float(moon_longitude) % 360.0 // NAKSHATRA_SPAN)
|
||||
|
||||
|
||||
def dwadashottari_applicability(birth_info: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Resolve Sukramsaka through an explicit caller-selected profile."""
|
||||
profile = str(birth_info.get("sukramsaka_profile") or "")
|
||||
if not profile:
|
||||
return {
|
||||
"applicable": False,
|
||||
"execution_status": "blocked",
|
||||
"rule_family": "bphs_dwadashottari_sukramsaka_profile_required",
|
||||
"reason": "Dwadashottari requires an explicit sukramsaka_profile because sources disagree on the exact applicability meaning.",
|
||||
}
|
||||
if profile == "navamsa_lagna_taurus_or_libra":
|
||||
value = birth_info.get("navamsa_ascendant_sign_index")
|
||||
if value is None:
|
||||
return {
|
||||
"applicable": False,
|
||||
"execution_status": "blocked",
|
||||
"rule_family": "bphs_dwadashottari_navamsa_lagna_venus_signs",
|
||||
"reason": "Missing required input for navamsa_lagna_taurus_or_libra: navamsa_ascendant_sign_index",
|
||||
}
|
||||
sign = int(value) % 12
|
||||
return {
|
||||
"applicable": sign in VENUS_SIGNS,
|
||||
"sukramsaka_profile": profile,
|
||||
"navamsa_ascendant_sign_index": sign,
|
||||
"rule_family": "bphs_dwadashottari_navamsa_lagna_venus_signs",
|
||||
"reason": (
|
||||
"Navamsa Lagna is Taurus or Libra."
|
||||
if sign in VENUS_SIGNS
|
||||
else "This profile requires Navamsa Lagna in Taurus or Libra."
|
||||
),
|
||||
}
|
||||
if profile == "lagna_navamsa_matches_venus_rasi":
|
||||
required = ("navamsa_ascendant_sign_index", "venus_rasi_sign_index")
|
||||
missing = [field for field in required if birth_info.get(field) is None]
|
||||
if missing:
|
||||
return {
|
||||
"applicable": False,
|
||||
"execution_status": "blocked",
|
||||
"rule_family": "bphs_dwadashottari_lagna_navamsa_matches_venus_rasi",
|
||||
"reason": "Missing required input for lagna_navamsa_matches_venus_rasi: " + ", ".join(missing),
|
||||
}
|
||||
lagna_navamsa = int(birth_info["navamsa_ascendant_sign_index"]) % 12
|
||||
venus_rasi = int(birth_info["venus_rasi_sign_index"]) % 12
|
||||
return {
|
||||
"applicable": lagna_navamsa == venus_rasi,
|
||||
"sukramsaka_profile": profile,
|
||||
"navamsa_ascendant_sign_index": lagna_navamsa,
|
||||
"venus_rasi_sign_index": venus_rasi,
|
||||
"rule_family": "bphs_dwadashottari_lagna_navamsa_matches_venus_rasi",
|
||||
"reason": (
|
||||
"Navamsa Lagna matches Venus's Rasi sign."
|
||||
if lagna_navamsa == venus_rasi
|
||||
else "This profile requires Navamsa Lagna to match Venus's Rasi sign."
|
||||
),
|
||||
}
|
||||
if profile == "lagna_rasi_matches_venus_navamsa":
|
||||
required = ("ascendant_sign_index", "venus_navamsa_sign_index")
|
||||
missing = [field for field in required if birth_info.get(field) is None]
|
||||
if missing:
|
||||
return {
|
||||
"applicable": False,
|
||||
"execution_status": "blocked",
|
||||
"rule_family": "bphs_dwadashottari_lagna_rasi_matches_venus_navamsa",
|
||||
"reason": "Missing required input for lagna_rasi_matches_venus_navamsa: " + ", ".join(missing),
|
||||
}
|
||||
lagna_rasi = int(birth_info["ascendant_sign_index"]) % 12
|
||||
venus_navamsa = int(birth_info["venus_navamsa_sign_index"]) % 12
|
||||
return {
|
||||
"applicable": lagna_rasi == venus_navamsa,
|
||||
"sukramsaka_profile": profile,
|
||||
"ascendant_sign_index": lagna_rasi,
|
||||
"venus_navamsa_sign_index": venus_navamsa,
|
||||
"rule_family": "bphs_dwadashottari_lagna_rasi_matches_venus_navamsa",
|
||||
"reason": (
|
||||
"Lagna Rasi matches Venus's Navamsa sign."
|
||||
if lagna_rasi == venus_navamsa
|
||||
else "This profile requires Lagna Rasi to match Venus's Navamsa sign."
|
||||
),
|
||||
}
|
||||
return {
|
||||
"applicable": False,
|
||||
"execution_status": "blocked",
|
||||
"rule_family": "bphs_dwadashottari_sukramsaka_profile_unknown",
|
||||
"reason": "Unknown sukramsaka_profile: " + profile,
|
||||
}
|
||||
|
||||
|
||||
def _sequence_from(lord: str) -> list[tuple[str, int]]:
|
||||
start = next(index for index, (item, _years) in enumerate(DASHA_SEQUENCE) if item == lord)
|
||||
return list(DASHA_SEQUENCE[start:] + DASHA_SEQUENCE[:start])
|
||||
|
||||
|
||||
def _birth_lord_and_balance(moon_longitude: float) -> tuple[str, float, int, int]:
|
||||
moon = float(moon_longitude) % 360.0
|
||||
nakshatra = _nakshatra_index(moon)
|
||||
count_to_revati = (REVATI_INDEX - nakshatra) % 27 + 1
|
||||
remainder = count_to_revati % len(DASHA_SEQUENCE)
|
||||
sequence_index = (remainder - 1) if remainder else len(DASHA_SEQUENCE) - 1
|
||||
lord = DASHA_SEQUENCE[sequence_index][0]
|
||||
balance = _YEARS[lord] * (1.0 - (moon % NAKSHATRA_SPAN) / NAKSHATRA_SPAN)
|
||||
return lord, balance, nakshatra, count_to_revati
|
||||
|
||||
|
||||
def calculate_dwadashottari_dasha(birth_info: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return birth-forward MD rows for an explicit Sukramsaka profile."""
|
||||
required = ("birth_datetime", "moon_longitude", "sukramsaka_profile")
|
||||
missing = [field for field in required if birth_info.get(field) is None]
|
||||
if missing:
|
||||
return {
|
||||
"applicable": False,
|
||||
"execution_status": "blocked",
|
||||
"confidence_status": "blocked",
|
||||
"reason": "Missing required input: " + ", ".join(missing),
|
||||
"major": [],
|
||||
}
|
||||
birth = birth_info["birth_datetime"]
|
||||
if isinstance(birth, str):
|
||||
birth = datetime.fromisoformat(birth)
|
||||
applicability = dwadashottari_applicability(birth_info)
|
||||
if not applicability["applicable"]:
|
||||
if birth_info.get("standard_table_profile") == "pl9_reports_all_dasha_tables_v1":
|
||||
applicability = {
|
||||
**applicability,
|
||||
"classical_applicable": False,
|
||||
"applicable": True,
|
||||
"standard_table_profile": "pl9_reports_all_dasha_tables_v1",
|
||||
"applicability_reason": applicability["reason"],
|
||||
"reason": (
|
||||
"PL9 standard-table profile requested: generate the report table while preserving "
|
||||
"the unmet or disputed classical applicability gate in the audit fields."
|
||||
),
|
||||
}
|
||||
else:
|
||||
return {
|
||||
**applicability,
|
||||
"execution_status": applicability.get("execution_status", "not_applicable"),
|
||||
"confidence_status": "blocked" if applicability.get("execution_status") == "blocked" else "not_applicable",
|
||||
"total_cycle": TOTAL_CYCLE,
|
||||
"major": [],
|
||||
}
|
||||
year_days = float(birth_info.get("dasha_year_days") or YEAR_DAYS)
|
||||
lord, balance, nakshatra, count_to_revati = _birth_lord_and_balance(float(birth_info["moon_longitude"]))
|
||||
rows = []
|
||||
cursor = birth
|
||||
for index, (row_lord, full_years) in enumerate(_sequence_from(lord)):
|
||||
years = balance if index == 0 else float(full_years)
|
||||
end = cursor + timedelta(days=years * year_days)
|
||||
rows.append(
|
||||
{
|
||||
"level": "mahadasha",
|
||||
"lord": row_lord,
|
||||
"planet": row_lord,
|
||||
"years": years,
|
||||
"full_years": full_years,
|
||||
"is_birth_balance": index == 0,
|
||||
"start_date": cursor.isoformat(timespec="seconds"),
|
||||
"end_date": end.isoformat(timespec="seconds"),
|
||||
}
|
||||
)
|
||||
cursor = end
|
||||
return {
|
||||
**applicability,
|
||||
"execution_status": "executed",
|
||||
"confidence_status": "parameter_sensitive",
|
||||
"verification_status": "source_bounded_local_candidate",
|
||||
"period_depth_status": "mahadasha_executed_ad_pd_blocked_pending_row_level_replay",
|
||||
"total_cycle": TOTAL_CYCLE,
|
||||
"dasha_year_days": year_days,
|
||||
"moon_nakshatra_index": nakshatra,
|
||||
"count_to_revati_inclusive": count_to_revati,
|
||||
"starting_lord": lord,
|
||||
"dasha_balance_at_birth_years": balance,
|
||||
"major": rows,
|
||||
"reason": "Local source-bounded MD calculation for an explicit Sukramsaka profile; AD/PD and profile arbitration remain gated.",
|
||||
"source_locator": "Dwadashottari source passages: Sukramsaka condition variants, count birth nakshatra to Revati, eight-lord order, and 7-21 year allotments.",
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Source-bounded Dwisaptatisama (72-year) Mahadasha producer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
|
||||
YEAR_DAYS = 365.25636
|
||||
NAKSHATRA_SPAN = 360.0 / 27.0
|
||||
MULA_INDEX = 18
|
||||
TOTAL_CYCLE = 72
|
||||
DASHA_SEQUENCE = (
|
||||
("Sun", 9),
|
||||
("Moon", 9),
|
||||
("Mars", 9),
|
||||
("Mercury", 9),
|
||||
("Jupiter", 9),
|
||||
("Venus", 9),
|
||||
("Saturn", 9),
|
||||
("Rahu", 9),
|
||||
)
|
||||
_YEARS = dict(DASHA_SEQUENCE)
|
||||
|
||||
|
||||
def _sign_index(longitude: float) -> int:
|
||||
return int(float(longitude) % 360.0 // 30.0)
|
||||
|
||||
|
||||
def _house_from_lagna(*, ascendant_longitude: float, planet_sign_index: int) -> int:
|
||||
return ((int(planet_sign_index) % 12 - _sign_index(ascendant_longitude)) % 12) + 1
|
||||
|
||||
|
||||
def dwisaptatisama_applicability(
|
||||
*, ascendant_longitude: float, lagna_lord_sign_index: int
|
||||
) -> dict[str, Any]:
|
||||
"""Apply the BPHS condition: Lagna lord in Lagna or seventh."""
|
||||
ascendant_sign = _sign_index(ascendant_longitude)
|
||||
lord_sign = int(lagna_lord_sign_index) % 12
|
||||
lord_house = _house_from_lagna(
|
||||
ascendant_longitude=ascendant_longitude,
|
||||
planet_sign_index=lord_sign,
|
||||
)
|
||||
applicable = lord_house in (1, 7)
|
||||
return {
|
||||
"applicable": applicable,
|
||||
"ascendant_sign_index": ascendant_sign,
|
||||
"lagna_lord_sign_index": lord_sign,
|
||||
"lagna_lord_house": lord_house,
|
||||
"rule_family": "bphs_dwisaptatisama_lagna_lord_in_first_or_seventh",
|
||||
"reason": (
|
||||
"Lagna lord is placed in the 1st or 7th house."
|
||||
if applicable
|
||||
else "Dwisaptatisama requires Lagna lord in the 1st or 7th house."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _sequence_from(lord: str) -> list[tuple[str, int]]:
|
||||
start = next(index for index, (item, _years) in enumerate(DASHA_SEQUENCE) if item == lord)
|
||||
return list(DASHA_SEQUENCE[start:] + DASHA_SEQUENCE[:start])
|
||||
|
||||
|
||||
def _birth_lord_and_balance(moon_longitude: float) -> tuple[str, float, int]:
|
||||
moon = float(moon_longitude) % 360.0
|
||||
nakshatra = int(moon // NAKSHATRA_SPAN)
|
||||
lord = DASHA_SEQUENCE[((nakshatra - MULA_INDEX) % 27) % len(DASHA_SEQUENCE)][0]
|
||||
balance = _YEARS[lord] * (1.0 - (moon % NAKSHATRA_SPAN) / NAKSHATRA_SPAN)
|
||||
return lord, balance, nakshatra
|
||||
|
||||
|
||||
def calculate_dwisaptatisama_dasha(birth_info: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return birth-forward MD rows; child-period boundaries stay blocked."""
|
||||
required = ("birth_datetime", "ascendant_longitude", "lagna_lord_sign_index", "moon_longitude")
|
||||
missing = [field for field in required if birth_info.get(field) is None]
|
||||
if missing:
|
||||
return {
|
||||
"applicable": False,
|
||||
"execution_status": "blocked",
|
||||
"confidence_status": "blocked",
|
||||
"reason": "Missing required input: " + ", ".join(missing),
|
||||
"major": [],
|
||||
}
|
||||
birth = birth_info["birth_datetime"]
|
||||
if isinstance(birth, str):
|
||||
birth = datetime.fromisoformat(birth)
|
||||
applicability = dwisaptatisama_applicability(
|
||||
ascendant_longitude=float(birth_info["ascendant_longitude"]),
|
||||
lagna_lord_sign_index=int(birth_info["lagna_lord_sign_index"]),
|
||||
)
|
||||
if not applicability["applicable"]:
|
||||
if birth_info.get("standard_table_profile") == "pl9_reports_all_dasha_tables_v1":
|
||||
applicability = {
|
||||
**applicability,
|
||||
"classical_applicable": False,
|
||||
"applicable": True,
|
||||
"standard_table_profile": "pl9_reports_all_dasha_tables_v1",
|
||||
"applicability_reason": applicability["reason"],
|
||||
"reason": (
|
||||
"PL9 standard-table profile requested: generate the report table while preserving "
|
||||
"the unmet classical applicability gate in the audit fields."
|
||||
),
|
||||
}
|
||||
else:
|
||||
return {
|
||||
**applicability,
|
||||
"execution_status": "not_applicable",
|
||||
"confidence_status": "not_applicable",
|
||||
"total_cycle": TOTAL_CYCLE,
|
||||
"major": [],
|
||||
}
|
||||
year_days = float(birth_info.get("dasha_year_days") or YEAR_DAYS)
|
||||
lord, balance, nakshatra = _birth_lord_and_balance(float(birth_info["moon_longitude"]))
|
||||
cursor = birth
|
||||
rows = []
|
||||
for index, (row_lord, full_years) in enumerate(_sequence_from(lord)):
|
||||
years = balance if index == 0 else float(full_years)
|
||||
end = cursor + timedelta(days=years * year_days)
|
||||
rows.append(
|
||||
{
|
||||
"level": "mahadasha",
|
||||
"lord": row_lord,
|
||||
"planet": row_lord,
|
||||
"years": years,
|
||||
"full_years": full_years,
|
||||
"is_birth_balance": index == 0,
|
||||
"start_date": cursor.isoformat(timespec="seconds"),
|
||||
"end_date": end.isoformat(timespec="seconds"),
|
||||
}
|
||||
)
|
||||
cursor = end
|
||||
return {
|
||||
**applicability,
|
||||
"execution_status": "executed",
|
||||
"confidence_status": "parameter_sensitive",
|
||||
"verification_status": "source_bounded_local_candidate",
|
||||
"period_depth_status": "mahadasha_executed_ad_pd_blocked_pending_row_level_replay",
|
||||
"total_cycle": TOTAL_CYCLE,
|
||||
"dasha_year_days": year_days,
|
||||
"moon_nakshatra_index": nakshatra,
|
||||
"starting_lord": lord,
|
||||
"dasha_balance_at_birth_years": balance,
|
||||
"major": rows,
|
||||
"reason": "Local source-bounded MD calculation; AD/PD are intentionally blocked pending row-level parity and runtime wiring.",
|
||||
"source_locator": "BPHS conditional Dasha passages: Lagna-lord-in-1st-or-7th gate, Mula count, Sun-to-Rahu order, and 9-year allotments.",
|
||||
}
|
||||
@@ -5596,6 +5596,8 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
|
||||
'monthly_frame': user_narrative.get('monthly_frame', {}),
|
||||
},
|
||||
))
|
||||
from relationship_event_class_evidence import build_event_class_evidence
|
||||
items.extend(build_event_class_evidence(relationship, self._theme_evidence))
|
||||
return items
|
||||
|
||||
def _derived_career_evidence(self, chart_data, context):
|
||||
@@ -7223,6 +7225,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
|
||||
'VedAstro 官方全量快照是第一原始证据层;若该层 blocked,必须把本地结果标记为 fallback。',
|
||||
'必须按 promise → activation → manifestation → label 输出;每个判断都要说明属于承诺、激活、落地形式还是标签层。',
|
||||
'未完成 MEVG / Real Case Calibration 时必须降级或标 blocked,不得把内部一致性写成已验证结论。',
|
||||
'婚恋问题第一句冻结事件类 romantic_activation / relationship_formation / legal_marriage;心动不得写成领证。Dasha 日期只许引用引擎输出。',
|
||||
]
|
||||
oracle_progress = {
|
||||
'scope': 'external_oracle_evidence_validation',
|
||||
@@ -7835,7 +7838,8 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
|
||||
planets, planet_lons = self._planet_lons_from_body(body)
|
||||
normalized, _, asc_sign_idx = self._normalized_planets_from_body({'planets': planets, 'ascendant': {'sign': asc_sign}})
|
||||
planets_for_analysis = normalized or planets
|
||||
result = analyze_relationship(planets_for_analysis, asc_sign)
|
||||
dasha_info = body.get('dasha_info') if isinstance(body.get('dasha_info'), dict) else None
|
||||
result = analyze_relationship(planets_for_analysis, asc_sign, dasha_info=dasha_info)
|
||||
chart_data = {
|
||||
'ascendant': {'sign': asc_sign},
|
||||
'planets': planets_for_analysis,
|
||||
|
||||
+145
-3
@@ -2624,11 +2624,20 @@ def _native_dasha_family_status(module) -> dict:
|
||||
return {'execution_status': 'blocked', 'reason': 'native_module_absent'}
|
||||
if module.get('error'):
|
||||
return {'execution_status': 'blocked', 'reason': str(module.get('error'))}
|
||||
execution_status = str(module.get('execution_status') or '').strip().lower()
|
||||
status = str(module.get('status') or '').strip().lower()
|
||||
if status in {'blocked', 'error'}:
|
||||
if execution_status == 'not_applicable' or status == 'not_applicable':
|
||||
applicability = module.get('applicability') if isinstance(module.get('applicability'), dict) else {}
|
||||
reason = module.get('reason') or applicability.get('reason') or 'not_applicable'
|
||||
return {
|
||||
'execution_status': 'not_applicable',
|
||||
'status': 'not_applicable',
|
||||
'reason': str(reason),
|
||||
}
|
||||
if status in {'blocked', 'error'} or execution_status in {'blocked', 'error'}:
|
||||
return {
|
||||
'execution_status': 'blocked',
|
||||
'reason': module.get('reason') or module.get('status') or 'blocked',
|
||||
'reason': module.get('reason') or module.get('status') or execution_status or 'blocked',
|
||||
}
|
||||
return {
|
||||
'execution_status': 'executed',
|
||||
@@ -2644,6 +2653,16 @@ def _native_dasha_master_families(modules: dict) -> dict:
|
||||
'yogini': modules.get('yogini_dasha'),
|
||||
'ashtottari': modules.get('ashtottari_dasha'),
|
||||
'kala_chakra': modules.get('kalachakra_dasha'),
|
||||
'tribhagi': modules.get('tribhagi_dasha'),
|
||||
'tribhagi_40': modules.get('tribhagi_40_dasha'),
|
||||
'shodashottari': modules.get('shodashottari_dasha'),
|
||||
'dwadashottari': modules.get('dwadashottari_dasha'),
|
||||
'dwisaptatisama': modules.get('dwisaptatisama_dasha'),
|
||||
'shattrimshatsama': modules.get('shattrimshatsama_dasha'),
|
||||
'panchottari': modules.get('panchottari_dasha'),
|
||||
'satabdika': modules.get('satabdika_dasha'),
|
||||
'chaturshitisama': modules.get('chaturshitisama_dasha'),
|
||||
'shastihayani': modules.get('shastihayani_dasha'),
|
||||
}
|
||||
families = {}
|
||||
for key, module in mapping.items():
|
||||
@@ -4022,6 +4041,16 @@ def render_pl9_markdown(packet: dict) -> str:
|
||||
'yogini_dasha': timing_sheet.get('yogini_dasha') or modules.get('yogini_dasha'),
|
||||
'ashtottari_dasha': timing_sheet.get('ashtottari_dasha') or modules.get('ashtottari_dasha'),
|
||||
'kalachakra_dasha': timing_sheet.get('kalachakra_dasha') or modules.get('kalachakra_dasha'),
|
||||
'tribhagi_dasha': timing_sheet.get('tribhagi_dasha') or modules.get('tribhagi_dasha'),
|
||||
'tribhagi_40_dasha': timing_sheet.get('tribhagi_40_dasha') or modules.get('tribhagi_40_dasha'),
|
||||
'shodashottari_dasha': timing_sheet.get('shodashottari_dasha') or modules.get('shodashottari_dasha'),
|
||||
'dwadashottari_dasha': timing_sheet.get('dwadashottari_dasha') or modules.get('dwadashottari_dasha'),
|
||||
'dwisaptatisama_dasha': timing_sheet.get('dwisaptatisama_dasha') or modules.get('dwisaptatisama_dasha'),
|
||||
'shattrimshatsama_dasha': timing_sheet.get('shattrimshatsama_dasha') or modules.get('shattrimshatsama_dasha'),
|
||||
'panchottari_dasha': timing_sheet.get('panchottari_dasha') or modules.get('panchottari_dasha'),
|
||||
'satabdika_dasha': timing_sheet.get('satabdika_dasha') or modules.get('satabdika_dasha'),
|
||||
'chaturshitisama_dasha': timing_sheet.get('chaturshitisama_dasha') or modules.get('chaturshitisama_dasha'),
|
||||
'shastihayani_dasha': timing_sheet.get('shastihayani_dasha') or modules.get('shastihayani_dasha'),
|
||||
})
|
||||
|
||||
def _family_status_row(label: str, key: str, confidence_when_executed: str, blocked_text: str) -> None:
|
||||
@@ -4036,6 +4065,9 @@ def render_pl9_markdown(packet: dict) -> str:
|
||||
reason_text = blocked_reason_labels.get(reason, reason)
|
||||
if reason_text == '本地引擎当前把这套大运标记为不适用,因此仍保留在核对层。':
|
||||
reason_text = '本地引擎当前把这套大运标记为不适用。'
|
||||
if execution_status == 'not_applicable':
|
||||
rows.append((label, 'not_applicable', '—', str(reason_text)))
|
||||
return
|
||||
rows.append((label, 'blocked', 'blocked', str(reason_text)))
|
||||
|
||||
_family_status_row('Vimshottari', 'vimshottari', 'parameter_sensitive', '主时间线已接入正文;当前已能给出 Dasha at Birth / Balance of Dasha / Current Dasha,但仍不升级为确定事件断言。')
|
||||
@@ -4043,6 +4075,16 @@ def render_pl9_markdown(packet: dict) -> str:
|
||||
_family_status_row('Yogini', 'yogini', 'parameter_sensitive', 'Yogini 周期已返回,但外部起始规则与边界 parity 尚未闭环。')
|
||||
_family_status_row('Ashtottari', 'ashtottari', 'parameter_sensitive / unverified', 'Ashtottari 已返回本地候选规则,但外部 profile 对照尚未闭环。')
|
||||
_family_status_row('Kala Chakra', 'kala_chakra', 'parameter_sensitive', 'Kala Chakra 本地周期已返回,但算法冲突标签仍需保留。')
|
||||
_family_status_row('Tribhagi', 'tribhagi', 'parameter_sensitive', '条件大运:子周期为 PyJHora 等分法,未做多引擎 parity。')
|
||||
_family_status_row('Tribhagi 40', 'tribhagi_40', 'parameter_sensitive', '条件大运:40 年单份;子周期为 PyJHora 等分法,未做多引擎 parity。')
|
||||
_family_status_row('Shodashottari', 'shodashottari', 'parameter_sensitive', 'Shodashottari:需上升在太阳 Hora 且白分 / 月亮 Hora 且黑分。')
|
||||
_family_status_row('Dwadashottari', 'dwadashottari', 'parameter_sensitive', '条件大运:适用性由模块判定;子周期为 PyJHora 等分法,未做多引擎 parity。')
|
||||
_family_status_row('Dwisaptatisama', 'dwisaptatisama', 'parameter_sensitive', '条件大运:适用性由模块判定;子周期为 PyJHora 等分法,未做多引擎 parity。')
|
||||
_family_status_row('Shastihayani', 'shastihayani', 'parameter_sensitive', '条件大运:适用性由模块判定;子周期为 PyJHora 等分法,未做多引擎 parity。')
|
||||
_family_status_row('Shattrimshatsama', 'shattrimshatsama', 'parameter_sensitive', '条件大运:适用性由模块判定;子周期为 PyJHora 等分法,未做多引擎 parity。')
|
||||
_family_status_row('Panchottari', 'panchottari', 'parameter_sensitive', '条件大运:适用性由模块判定;子周期为 PyJHora 等分法,未做多引擎 parity。')
|
||||
_family_status_row('Satabdika', 'satabdika', 'parameter_sensitive', '条件大运:适用性由模块判定;子周期为 PyJHora 等分法,未做多引擎 parity。')
|
||||
_family_status_row('Chaturshitisama', 'chaturshitisama', 'parameter_sensitive', '条件大运:适用性由模块判定;子周期为 PyJHora 等分法,未做多引擎 parity。')
|
||||
if year_lord:
|
||||
rows.append(('Year Lord', 'executed', 'pyjhora_behavior_only / not_multiengine_parity', '仅锁定 PyJHora 行为,尚未完成多引擎 parity/replay。'))
|
||||
if not rows:
|
||||
@@ -16328,13 +16370,65 @@ def cmd_full_reading(args):
|
||||
moon_nakshatra_index = int(moon_lon / (360 / 27)) % 27
|
||||
moon_pada = int((moon_lon % (360 / 27)) / (360 / 108)) + 1
|
||||
tithi_number = int(((moon_lon - planet_lons.get('Sun', 0)) % 360) / 12) + 1
|
||||
birth_dt = _birth_datetime_from_args(args)
|
||||
ll_name = SIGN_LORDS[SIGNS[asc_idx]]
|
||||
rahu_sign_index = int(float(planet_lons.get('Rahu', 0)) / 30) % 12
|
||||
lagna_lord_sign_index = int(float(planet_lons.get(ll_name, 0)) / 30) % 12
|
||||
venus_rasi_sign_index = int(float(planet_lons.get('Venus', 0)) / 30) % 12
|
||||
tenth_house_sign_index = (asc_idx + 9) % 12
|
||||
tenth_lord_name = SIGN_LORDS[SIGNS[tenth_house_sign_index]]
|
||||
tenth_lord_sign_index = int(float(planet_lons.get(tenth_lord_name, 0)) / 30) % 12
|
||||
try:
|
||||
from varga import calc_varga
|
||||
|
||||
navamsa_ascendant_sign_index = int(calc_varga(asc_deg, 9).get('sign_idx', 0)) % 12
|
||||
venus_navamsa_sign_index = int(calc_varga(float(planet_lons.get('Venus', 0)), 9).get('sign_idx', 0)) % 12
|
||||
except Exception:
|
||||
navamsa_ascendant_sign_index = None
|
||||
venus_navamsa_sign_index = None
|
||||
ascendant_degree_in_sign = float(asc_deg) % 30.0
|
||||
ascendant_hora_lord = (
|
||||
'Sun'
|
||||
if (asc_idx % 2 == 0 and ascendant_degree_in_sign < 15.0)
|
||||
or (asc_idx % 2 == 1 and ascendant_degree_in_sign >= 15.0)
|
||||
else 'Moon'
|
||||
)
|
||||
birth_is_daytime = None
|
||||
try:
|
||||
from saham_daynight import determine_daytime
|
||||
|
||||
daynight = determine_daytime(birth_dt, lat=args.lat, lon=args.lon, tz=args.tz)
|
||||
birth_is_daytime = bool(daynight.get("is_daytime"))
|
||||
except Exception:
|
||||
birth_is_daytime = None
|
||||
applicability_rule_family = (
|
||||
'rahu_from_lagna_lord_kendra_trikona_plus_paksha_daynight'
|
||||
if birth_is_daytime is not None
|
||||
else 'moon_nakshatra_plus_paksha'
|
||||
)
|
||||
birth_info_for_alt_dasha = {
|
||||
'birth_datetime': _birth_datetime_from_args(args),
|
||||
'birth_datetime': birth_dt,
|
||||
'moon_nakshatra_index': moon_nakshatra_index,
|
||||
'moon_pada': moon_pada,
|
||||
'is_shukla_paksha': 1 <= tithi_number <= 15,
|
||||
'lagna_rashi_index': asc_idx,
|
||||
}
|
||||
conditional_birth_info = {
|
||||
**birth_info_for_alt_dasha,
|
||||
'ascendant_longitude': asc_deg,
|
||||
'sun_longitude': planet_lons.get('Sun', 0),
|
||||
'moon_longitude': moon_lon,
|
||||
'birth_is_daytime': birth_is_daytime,
|
||||
'ascendant_sign_index': asc_idx,
|
||||
'ascendant_hora_lord': ascendant_hora_lord,
|
||||
'navamsa_ascendant_sign_index': navamsa_ascendant_sign_index,
|
||||
'venus_rasi_sign_index': venus_rasi_sign_index,
|
||||
'venus_navamsa_sign_index': venus_navamsa_sign_index,
|
||||
'tenth_lord_sign_index': tenth_lord_sign_index,
|
||||
'rahu_sign_index': rahu_sign_index,
|
||||
'lagna_lord_sign_index': lagna_lord_sign_index,
|
||||
'applicability_rule_family': applicability_rule_family,
|
||||
}
|
||||
|
||||
try:
|
||||
from yogini_dasha import calculate_yogini_dasha
|
||||
@@ -16360,6 +16454,54 @@ def cmd_full_reading(args):
|
||||
kalachakra_data = {'error': str(alt_e)}
|
||||
report['modules']['kalachakra_dasha'] = kalachakra_data
|
||||
|
||||
try:
|
||||
from source_bounded_child_periods import PYJHORA_PARENT_SEEDED_EQUAL_SPLIT_PROFILE
|
||||
except ModuleNotFoundError: # pragma: no cover - scripts/ on sys.path
|
||||
from scripts.source_bounded_child_periods import PYJHORA_PARENT_SEEDED_EQUAL_SPLIT_PROFILE
|
||||
|
||||
def _store_conditional_dasha(output_id, payload):
|
||||
if isinstance(payload, dict) and 'periods' not in payload:
|
||||
majors = payload.get('major')
|
||||
if isinstance(majors, list):
|
||||
payload = dict(payload)
|
||||
payload['periods'] = majors
|
||||
report['modules'][output_id] = payload
|
||||
|
||||
try:
|
||||
from shastihayani_dasha import calculate_shastihayani_dasha
|
||||
|
||||
shastihayani_birth_info = dict(conditional_birth_info)
|
||||
shastihayani_birth_info['child_period_profile'] = PYJHORA_PARENT_SEEDED_EQUAL_SPLIT_PROFILE
|
||||
_store_conditional_dasha(
|
||||
'shastihayani_dasha',
|
||||
calculate_shastihayani_dasha(shastihayani_birth_info),
|
||||
)
|
||||
except Exception as alt_e:
|
||||
report['modules']['shastihayani_dasha'] = {'error': str(alt_e)}
|
||||
|
||||
for output_id, module_id, function_name, extra in (
|
||||
('tribhagi_dasha', 'tribhagi_dasha', 'calculate_tribhagi_dasha', {'mode': 'full_120'}),
|
||||
('tribhagi_40_dasha', 'tribhagi_dasha', 'calculate_tribhagi_dasha', {'mode': 'single_bhaga_40'}),
|
||||
('shodashottari_dasha', 'shodashottari_dasha', 'calculate_shodashottari_dasha', {}),
|
||||
('dwadashottari_dasha', 'dwadashottari_dasha', 'calculate_dwadashottari_dasha', {'sukramsaka_profile': 'navamsa_lagna_taurus_or_libra'}),
|
||||
('dwisaptatisama_dasha', 'dwisaptatisama_dasha', 'calculate_dwisaptatisama_dasha', {}),
|
||||
('shattrimshatsama_dasha', 'shattrimshatsama_dasha', 'calculate_shattrimshatsama_dasha', {}),
|
||||
('panchottari_dasha', 'panchottari_dasha', 'calculate_panchottari_dasha', {}),
|
||||
('satabdika_dasha', 'satabdika_dasha', 'calculate_satabdika_dasha', {}),
|
||||
('chaturshitisama_dasha', 'chaturshitisama_dasha', 'calculate_chaturshitisama_dasha', {}),
|
||||
):
|
||||
try:
|
||||
module = __import__(module_id)
|
||||
calculator = getattr(module, function_name)
|
||||
alt_birth_info = {
|
||||
**conditional_birth_info,
|
||||
**extra,
|
||||
'child_period_profile': PYJHORA_PARENT_SEEDED_EQUAL_SPLIT_PROFILE,
|
||||
}
|
||||
_store_conditional_dasha(output_id, calculator(alt_birth_info))
|
||||
except Exception as alt_e:
|
||||
report['modules'][output_id] = {'error': str(alt_e)}
|
||||
|
||||
convergence = _calc_dasa_convergence(
|
||||
dasha_data,
|
||||
chara_dasha_data,
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Source-bounded Niryaana Shoola Dasha Mahadasha producer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
|
||||
YEAR_DAYS = 365.25636
|
||||
TOTAL_CYCLE = 108
|
||||
SIGNS = (
|
||||
"Aries",
|
||||
"Taurus",
|
||||
"Gemini",
|
||||
"Cancer",
|
||||
"Leo",
|
||||
"Virgo",
|
||||
"Libra",
|
||||
"Scorpio",
|
||||
"Sagittarius",
|
||||
"Capricorn",
|
||||
"Aquarius",
|
||||
"Pisces",
|
||||
)
|
||||
|
||||
|
||||
def _sign_index(longitude: float) -> int:
|
||||
return int(float(longitude) % 360.0 // 30.0)
|
||||
|
||||
|
||||
def _forward_order(start_sign_index: int) -> list[int]:
|
||||
return [(int(start_sign_index) + offset) % 12 for offset in range(12)]
|
||||
|
||||
|
||||
def niryaana_shoola_applicability(
|
||||
*, ascendant_longitude: float, lagna_strength: float, seventh_strength: float
|
||||
) -> dict[str, Any]:
|
||||
"""Select stronger Lagna/seventh start sign; unresolved ties stay blocked."""
|
||||
lagna_sign = _sign_index(ascendant_longitude)
|
||||
seventh_sign = (lagna_sign + 6) % 12
|
||||
lagna_strength = float(lagna_strength)
|
||||
seventh_strength = float(seventh_strength)
|
||||
if lagna_strength == seventh_strength:
|
||||
return {
|
||||
"applicable": False,
|
||||
"execution_status": "blocked",
|
||||
"ascendant_sign_index": lagna_sign,
|
||||
"seventh_sign_index": seventh_sign,
|
||||
"lagna_strength": lagna_strength,
|
||||
"seventh_strength": seventh_strength,
|
||||
"rule_family": "goel_niryaana_shoola_stronger_lagna_or_seventh",
|
||||
"reason": "Niryaana Shoola start sign requires a declared stronger Lagna or seventh sign; tie-break profile is not closed.",
|
||||
}
|
||||
start = lagna_sign if lagna_strength > seventh_strength else seventh_sign
|
||||
return {
|
||||
"applicable": True,
|
||||
"ascendant_sign_index": lagna_sign,
|
||||
"seventh_sign_index": seventh_sign,
|
||||
"lagna_strength": lagna_strength,
|
||||
"seventh_strength": seventh_strength,
|
||||
"starting_sign_index": start,
|
||||
"starting_sign": SIGNS[start],
|
||||
"rule_family": "goel_niryaana_shoola_stronger_lagna_or_seventh",
|
||||
"reason": "Niryaana Shoola starts from the stronger of Lagna and seventh sign.",
|
||||
}
|
||||
|
||||
|
||||
def calculate_niryaana_shoola_dasha(birth_info: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return forward 9-year sign rows; AD/PD remain blocked."""
|
||||
required = ("birth_datetime", "ascendant_longitude", "lagna_strength", "seventh_strength")
|
||||
missing = [field for field in required if birth_info.get(field) is None]
|
||||
if missing:
|
||||
return {
|
||||
"applicable": False,
|
||||
"execution_status": "blocked",
|
||||
"confidence_status": "blocked",
|
||||
"reason": "Missing required input: " + ", ".join(missing),
|
||||
"major": [],
|
||||
}
|
||||
birth = birth_info["birth_datetime"]
|
||||
if isinstance(birth, str):
|
||||
birth = datetime.fromisoformat(birth)
|
||||
applicability = niryaana_shoola_applicability(
|
||||
ascendant_longitude=float(birth_info["ascendant_longitude"]),
|
||||
lagna_strength=float(birth_info["lagna_strength"]),
|
||||
seventh_strength=float(birth_info["seventh_strength"]),
|
||||
)
|
||||
if not applicability["applicable"]:
|
||||
return {
|
||||
**applicability,
|
||||
"confidence_status": "blocked",
|
||||
"total_cycle": TOTAL_CYCLE,
|
||||
"major": [],
|
||||
}
|
||||
year_days = float(birth_info.get("dasha_year_days") or YEAR_DAYS)
|
||||
rows = []
|
||||
cursor = birth
|
||||
for sign_index in _forward_order(int(applicability["starting_sign_index"])):
|
||||
end = cursor + timedelta(days=9.0 * year_days)
|
||||
rows.append(
|
||||
{
|
||||
"level": "mahadasha",
|
||||
"sign_index": sign_index,
|
||||
"sign": SIGNS[sign_index],
|
||||
"years": 9,
|
||||
"start_date": cursor.isoformat(timespec="seconds"),
|
||||
"end_date": end.isoformat(timespec="seconds"),
|
||||
}
|
||||
)
|
||||
cursor = end
|
||||
return {
|
||||
**applicability,
|
||||
"execution_status": "executed",
|
||||
"confidence_status": "parameter_sensitive",
|
||||
"verification_status": "source_bounded_local_candidate",
|
||||
"period_depth_status": "mahadasha_executed_ad_pd_blocked_pending_row_level_replay",
|
||||
"total_cycle": TOTAL_CYCLE,
|
||||
"dasha_year_days": year_days,
|
||||
"major": rows,
|
||||
"reason": "Local source-bounded MD calculation; strength source, AD/PD, and runtime wiring remain explicitly gated.",
|
||||
"source_locator": "VP Goel / public Jaimini summaries: stronger Lagna-or-seventh start, direct order, 9 years per sign, 108-year cycle.",
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Independent Panchottari Dasha producer bounded to the published BPHS rule.
|
||||
|
||||
The legacy ``extended_dashas`` helper is deliberately not used: it is a
|
||||
generic placeholder and does not model Panchottari's seven lords, ascending
|
||||
period lengths, or Cancer/D12 applicability gate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from scripts.source_bounded_child_periods import expand_parent_seeded_equal_split_children
|
||||
except ModuleNotFoundError: # pragma: no cover - scripts/ on sys.path
|
||||
from source_bounded_child_periods import expand_parent_seeded_equal_split_children
|
||||
|
||||
|
||||
YEAR_DAYS = 365.25636
|
||||
NAKSHATRA_SPAN = 360.0 / 27.0
|
||||
ANURADHA_INDEX = 16
|
||||
TOTAL_CYCLE = 105
|
||||
DASHA_SEQUENCE = (
|
||||
("Sun", 12),
|
||||
("Mercury", 13),
|
||||
("Saturn", 14),
|
||||
("Mars", 15),
|
||||
("Venus", 16),
|
||||
("Moon", 17),
|
||||
("Jupiter", 18),
|
||||
)
|
||||
_YEARS_BY_LORD = dict(DASHA_SEQUENCE)
|
||||
|
||||
|
||||
def _sign_index(longitude: float) -> int:
|
||||
return int(float(longitude) % 360.0 // 30.0)
|
||||
|
||||
|
||||
def dwadasamsa_sign_index(longitude: float) -> int:
|
||||
"""Return the standard D12 sign: twelve 2.5-degree parts from the sign."""
|
||||
longitude = float(longitude) % 360.0
|
||||
return (_sign_index(longitude) + int((longitude % 30.0) // 2.5)) % 12
|
||||
|
||||
|
||||
def panchottari_applicability(*, ascendant_longitude: float) -> dict[str, Any]:
|
||||
"""Apply BPHS's Cancer-rasi plus Cancer-D12 Panchottari condition."""
|
||||
rasi_sign = _sign_index(ascendant_longitude)
|
||||
d12_sign = dwadasamsa_sign_index(ascendant_longitude)
|
||||
applicable = rasi_sign == 3 and d12_sign == 3 # Cancer is zero-based sign 3.
|
||||
return {
|
||||
"applicable": applicable,
|
||||
"ascendant_rasi_sign_index": rasi_sign,
|
||||
"ascendant_d12_sign_index": d12_sign,
|
||||
"rule_family": "bphs_panchottari_cancer_rasi_and_d12",
|
||||
"reason": (
|
||||
"Ascendant is Cancer in both Rasi and Dwadasamsa."
|
||||
if applicable
|
||||
else "Panchottari requires a Cancer Ascendant that is also Cancer in Dwadasamsa."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _sequence_from(starting_lord: str) -> list[tuple[str, int]]:
|
||||
start_index = next(index for index, (lord, _years) in enumerate(DASHA_SEQUENCE) if lord == starting_lord)
|
||||
return list(DASHA_SEQUENCE[start_index:] + DASHA_SEQUENCE[:start_index])
|
||||
|
||||
|
||||
def _birth_lord_and_balance(moon_longitude: float) -> tuple[str, float, int]:
|
||||
moon_longitude = float(moon_longitude) % 360.0
|
||||
nakshatra_index = int(moon_longitude // NAKSHATRA_SPAN)
|
||||
offset = (nakshatra_index - ANURADHA_INDEX) % 27
|
||||
starting_lord = DASHA_SEQUENCE[offset % len(DASHA_SEQUENCE)][0]
|
||||
fraction_elapsed = (moon_longitude % NAKSHATRA_SPAN) / NAKSHATRA_SPAN
|
||||
remaining_years = _YEARS_BY_LORD[starting_lord] * (1.0 - fraction_elapsed)
|
||||
return starting_lord, remaining_years, nakshatra_index
|
||||
|
||||
|
||||
def _iso(value: datetime) -> str:
|
||||
return value.isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def calculate_panchottari_dasha(birth_info: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Calculate birth-forward Panchottari MD; AD/PD stay explicitly unclosed."""
|
||||
required = ("birth_datetime", "ascendant_longitude", "moon_longitude")
|
||||
missing = [field for field in required if birth_info.get(field) is None]
|
||||
if missing:
|
||||
return {
|
||||
"applicable": False,
|
||||
"execution_status": "blocked",
|
||||
"confidence_status": "blocked",
|
||||
"reason": "Missing required input: " + ", ".join(missing),
|
||||
"major": [],
|
||||
}
|
||||
birth_dt = birth_info["birth_datetime"]
|
||||
if isinstance(birth_dt, str):
|
||||
birth_dt = datetime.fromisoformat(birth_dt)
|
||||
applicability = panchottari_applicability(ascendant_longitude=float(birth_info["ascendant_longitude"]))
|
||||
if not applicability["applicable"]:
|
||||
if birth_info.get("standard_table_profile") == "pl9_reports_all_dasha_tables_v1":
|
||||
applicability = {
|
||||
**applicability,
|
||||
"classical_applicable": False,
|
||||
"applicable": True,
|
||||
"standard_table_profile": "pl9_reports_all_dasha_tables_v1",
|
||||
"applicability_reason": applicability["reason"],
|
||||
"reason": (
|
||||
"PL9 standard-table profile requested: generate the report table while preserving "
|
||||
"the unmet classical applicability gate in the audit fields."
|
||||
),
|
||||
}
|
||||
else:
|
||||
return {
|
||||
**applicability,
|
||||
"execution_status": "not_applicable",
|
||||
"confidence_status": "not_applicable",
|
||||
"total_cycle": TOTAL_CYCLE,
|
||||
"major": [],
|
||||
}
|
||||
year_days = float(birth_info.get("dasha_year_days") or YEAR_DAYS)
|
||||
starting_lord, balance_years, moon_nakshatra_index = _birth_lord_and_balance(float(birth_info["moon_longitude"]))
|
||||
periods = []
|
||||
cursor = birth_dt
|
||||
for index, (lord, full_years) in enumerate(_sequence_from(starting_lord)):
|
||||
years = balance_years if index == 0 else float(full_years)
|
||||
end = cursor + timedelta(days=years * year_days)
|
||||
period = {
|
||||
"level": "mahadasha",
|
||||
"planet": lord,
|
||||
"lord": lord,
|
||||
"years": years,
|
||||
"full_years": full_years,
|
||||
"is_birth_balance": index == 0,
|
||||
"start_date": _iso(cursor),
|
||||
"end_date": _iso(end),
|
||||
}
|
||||
periods.append(period)
|
||||
cursor = end
|
||||
child_profile = birth_info.get("child_period_profile")
|
||||
periods, antardasha, pratyantardasha, period_depth_status = expand_parent_seeded_equal_split_children(
|
||||
periods,
|
||||
DASHA_SEQUENCE,
|
||||
profile=str(child_profile) if child_profile else None,
|
||||
)
|
||||
return {
|
||||
**applicability,
|
||||
"execution_status": "executed",
|
||||
"confidence_status": "parameter_sensitive",
|
||||
"verification_status": "source_bounded_local_candidate",
|
||||
"period_depth_status": period_depth_status,
|
||||
"total_cycle": TOTAL_CYCLE,
|
||||
"dasha_year_days": year_days,
|
||||
"moon_nakshatra_index": moon_nakshatra_index,
|
||||
"starting_lord": starting_lord,
|
||||
"dasha_balance_at_birth_years": balance_years,
|
||||
"child_period_profile": child_profile or None,
|
||||
"major": periods,
|
||||
"antardasha": antardasha,
|
||||
"pratyantardasha": pratyantardasha,
|
||||
"reason": (
|
||||
"Local source-bounded MD calculation with optional PyJHora-observed child-period profile."
|
||||
if child_profile else
|
||||
"Local source-bounded MD calculation; AD/PD are intentionally blocked pending row-level parity and runtime wiring."
|
||||
),
|
||||
"source_locator": "BPHS conditional Dasha passages: Cancer Rasi/D12 gate, Anuradha count, lord order, and 12-18 year allotments.",
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Punarphoo observation detector.
|
||||
|
||||
Natal rule: Moon-Saturn conjunction with circular orb <= 10°.
|
||||
Timing observation: transiting Saturn returning to natal Moon.
|
||||
|
||||
Claim boundary (hard):
|
||||
- This module is observation-only.
|
||||
- Punarphoo natal presence or Saturn-return-to-natal-Moon is not marriage.
|
||||
- Do not promote to truth / legal_marriage before negative holdout passes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
PUNARPHOO_ORB_DEG = 10.0
|
||||
SATURN_RETURN_TO_MOON_ORB_DEG = 1.0
|
||||
CLAIM_BOUNDARY = (
|
||||
"Punarphoo return is not marriage. Observation only; "
|
||||
"negative holdout not passed; do not promote to truth."
|
||||
)
|
||||
|
||||
|
||||
def circular_degree_gap(a: float, b: float) -> float:
|
||||
gap = abs(float(a) - float(b)) % 360.0
|
||||
return min(gap, 360.0 - gap)
|
||||
|
||||
|
||||
def _longitude(pdata: Any) -> Optional[float]:
|
||||
if not isinstance(pdata, dict):
|
||||
return None
|
||||
for key in ("longitude", "lon", "degree"):
|
||||
value = pdata.get(key)
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
return None
|
||||
|
||||
|
||||
def detect_natal_punarphoo(planets: Dict[str, Any] | None, orb_limit: float = PUNARPHOO_ORB_DEG) -> Dict[str, Any]:
|
||||
planets = planets if isinstance(planets, dict) else {}
|
||||
moon = planets.get("Moon") if isinstance(planets.get("Moon"), dict) else {}
|
||||
saturn = planets.get("Saturn") if isinstance(planets.get("Saturn"), dict) else {}
|
||||
moon_lon = _longitude(moon)
|
||||
saturn_lon = _longitude(saturn)
|
||||
same_house = moon.get("house") is not None and moon.get("house") == saturn.get("house")
|
||||
orb = circular_degree_gap(moon_lon, saturn_lon) if moon_lon is not None and saturn_lon is not None else None
|
||||
present = orb is not None and orb <= orb_limit
|
||||
return {
|
||||
"status": "observation_only",
|
||||
"natal_present": present,
|
||||
"orb_deg": orb,
|
||||
"orb_limit_deg": orb_limit,
|
||||
"same_house": bool(same_house),
|
||||
"moon_house": moon.get("house"),
|
||||
"saturn_house": saturn.get("house"),
|
||||
"claim_boundary": CLAIM_BOUNDARY,
|
||||
"source": "punarphoo_observation_v1",
|
||||
"production_tuning_allowed": False,
|
||||
}
|
||||
|
||||
|
||||
def detect_saturn_return_to_natal_moon(
|
||||
natal_moon_longitude: Optional[float],
|
||||
transit_saturn_longitude: Optional[float],
|
||||
orb_limit: float = SATURN_RETURN_TO_MOON_ORB_DEG,
|
||||
) -> Dict[str, Any]:
|
||||
if natal_moon_longitude is None or transit_saturn_longitude is None:
|
||||
return {
|
||||
"status": "observation_only",
|
||||
"active": False,
|
||||
"orb_deg": None,
|
||||
"orb_limit_deg": orb_limit,
|
||||
"claim_boundary": CLAIM_BOUNDARY,
|
||||
"source": "punarphoo_observation_v1",
|
||||
}
|
||||
orb = circular_degree_gap(natal_moon_longitude, transit_saturn_longitude)
|
||||
return {
|
||||
"status": "observation_only",
|
||||
"active": orb <= orb_limit,
|
||||
"orb_deg": orb,
|
||||
"orb_limit_deg": orb_limit,
|
||||
"claim_boundary": CLAIM_BOUNDARY,
|
||||
"source": "punarphoo_observation_v1",
|
||||
}
|
||||
|
||||
|
||||
def detect_punarphoo(
|
||||
planets: Dict[str, Any] | None,
|
||||
transit_saturn_longitude: Optional[float] = None,
|
||||
) -> Dict[str, Any]:
|
||||
natal = detect_natal_punarphoo(planets)
|
||||
moon_lon = _longitude((planets or {}).get("Moon")) if isinstance(planets, dict) else None
|
||||
transit = detect_saturn_return_to_natal_moon(moon_lon, transit_saturn_longitude)
|
||||
return {
|
||||
**natal,
|
||||
"saturn_return_to_natal_moon": transit,
|
||||
"timing_observation_active": bool(transit.get("active")),
|
||||
"claim_boundary": CLAIM_BOUNDARY,
|
||||
}
|
||||
@@ -8,7 +8,39 @@ Love & Relationship Analysis(感情分析)引擎 v1.0
|
||||
7宫+Venus → Navamsa(D9) → Upapada(UL) → Darakaraka → Synastry
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Any
|
||||
from typing import Dict, List, Any, Optional
|
||||
|
||||
try:
|
||||
from punarphoo import circular_degree_gap, detect_punarphoo
|
||||
except ModuleNotFoundError: # pragma: no cover - script/module dual import
|
||||
from scripts.punarphoo import circular_degree_gap, detect_punarphoo
|
||||
|
||||
try:
|
||||
from timing_negative_holdout_audit import build_report as build_negative_holdout_report
|
||||
except ModuleNotFoundError: # pragma: no cover - script/module dual import
|
||||
from scripts.timing_negative_holdout_audit import build_report as build_negative_holdout_report
|
||||
|
||||
SIGN_LORDS = {
|
||||
'Aries': 'Mars', 'Taurus': 'Venus', 'Gemini': 'Mercury', 'Cancer': 'Moon',
|
||||
'Leo': 'Sun', 'Virgo': 'Mercury', 'Libra': 'Venus', 'Scorpio': 'Mars',
|
||||
'Sagittarius': 'Jupiter', 'Capricorn': 'Saturn', 'Aquarius': 'Saturn', 'Pisces': 'Jupiter',
|
||||
}
|
||||
|
||||
ROMANTIC_ACTIVATION_LORDS = ('5L', '7H_Moon', 'Punarphoo')
|
||||
LEGAL_MARRIAGE_LORDS = ('Venus', 'DK', 'UL')
|
||||
# PD never drives event_class. 5L / 7H Moon / 7L / Venus legal hits use MD/AD only.
|
||||
# Same-structure real cases (female + Saturn-7H chain) trigger at AD, not at
|
||||
# annual transits. Do not invent a month proxy when the case layer only supports AD.
|
||||
MD_AD_KEYS = (
|
||||
'maha_dasha', 'mahadasha', 'antar_dasha', 'antardasha', 'bhukti',
|
||||
)
|
||||
PD_KEYS = ('pratyantar', 'pratyantar_dasha')
|
||||
VENUS_TO_NATAL_MOON_ORB_DEG = 2.0
|
||||
EVENT_CLASSES = (
|
||||
'romantic_activation',
|
||||
'relationship_formation',
|
||||
'legal_marriage',
|
||||
)
|
||||
|
||||
SIGNS = ['Aries','Taurus','Gemini','Cancer','Leo','Virgo',
|
||||
'Libra','Scorpio','Sagittarius','Capricorn','Aquarius','Pisces']
|
||||
@@ -44,6 +76,163 @@ RELATIONSHIP_TIMING = {
|
||||
}
|
||||
|
||||
|
||||
def _lord_of_house(asc_sign: str, house: int) -> Optional[str]:
|
||||
if asc_sign not in SIGNS or house < 1 or house > 12:
|
||||
return None
|
||||
sign = SIGNS[(SIGNS.index(asc_sign) + house - 1) % 12]
|
||||
return SIGN_LORDS.get(sign)
|
||||
|
||||
|
||||
def _lords_from_keys(dasha_info: Optional[Dict], keys) -> List[str]:
|
||||
if not isinstance(dasha_info, dict):
|
||||
return []
|
||||
lords: List[str] = []
|
||||
for key in keys:
|
||||
value = dasha_info.get(key)
|
||||
if isinstance(value, dict):
|
||||
value = value.get('lord') or value.get('planet')
|
||||
if isinstance(value, str) and value:
|
||||
lords.append(value)
|
||||
return lords
|
||||
|
||||
|
||||
def _dasha_lords(dasha_info: Optional[Dict]) -> List[str]:
|
||||
"""MD/AD lords only. PD stays in `_pd_lords` and must not drive event_class."""
|
||||
return _lords_from_keys(dasha_info, MD_AD_KEYS)
|
||||
|
||||
|
||||
def _pd_lords(dasha_info: Optional[Dict]) -> List[str]:
|
||||
return _lords_from_keys(dasha_info, PD_KEYS)
|
||||
|
||||
|
||||
def classify_relationship_event_class(
|
||||
planets: Dict,
|
||||
asc_sign: str = 'Aries',
|
||||
dasha_info: Optional[Dict] = None,
|
||||
ul_sign: Optional[str] = None,
|
||||
darakaraka: Optional[str] = None,
|
||||
transit_saturn_longitude: Optional[float] = None,
|
||||
transit_venus_longitude: Optional[float] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Split romantic activation / relationship formation / legal marriage.
|
||||
|
||||
Punarphoo and 5L remain observation-only until negative holdout passes.
|
||||
Never flatten the three tracks into one 婚恋机会 label.
|
||||
MD/AD drive event_class. PD is recorded but never promotes a class.
|
||||
"""
|
||||
planets = planets if isinstance(planets, dict) else {}
|
||||
fifth_lord = _lord_of_house(asc_sign, 5)
|
||||
seventh_lord = _lord_of_house(asc_sign, 7)
|
||||
moon = planets.get('Moon') if isinstance(planets.get('Moon'), dict) else {}
|
||||
moon_house = moon.get('house')
|
||||
moon_lon = moon.get('longitude')
|
||||
if moon_lon is None:
|
||||
moon_lon = moon.get('lon')
|
||||
dasha_lords = _dasha_lords(dasha_info)
|
||||
pd_lords = _pd_lords(dasha_info)
|
||||
punarphoo = detect_punarphoo(planets, transit_saturn_longitude=transit_saturn_longitude)
|
||||
holdout = build_negative_holdout_report()
|
||||
|
||||
natal_romantic: List[str] = []
|
||||
if moon_house == 7:
|
||||
natal_romantic.append('7H_Moon')
|
||||
if punarphoo.get('natal_present'):
|
||||
natal_romantic.append('natal_Punarphoo')
|
||||
|
||||
venus_to_moon = None
|
||||
if moon_house == 7 and moon_lon is not None and transit_venus_longitude is not None:
|
||||
venus_orb = circular_degree_gap(float(moon_lon), float(transit_venus_longitude))
|
||||
venus_to_moon = {
|
||||
'status': 'observation_only',
|
||||
'active': venus_orb <= VENUS_TO_NATAL_MOON_ORB_DEG,
|
||||
'orb_deg': venus_orb,
|
||||
'orb_limit_deg': VENUS_TO_NATAL_MOON_ORB_DEG,
|
||||
'drives_event_class': False,
|
||||
'claim_boundary': (
|
||||
'7H Venus-to-Moon is annual and not a crush month. '
|
||||
'Record only; never add to romantic_hits or point a month. '
|
||||
'Falsified by empty 2019-2022 repeats and 2026-02-13→18.'
|
||||
),
|
||||
}
|
||||
|
||||
romantic_hits: List[str] = []
|
||||
if fifth_lord and fifth_lord in dasha_lords:
|
||||
romantic_hits.append('5L_dasha')
|
||||
if moon_house == 7 and 'Moon' in dasha_lords:
|
||||
romantic_hits.append('7H_Moon_dasha')
|
||||
if punarphoo.get('timing_observation_active'):
|
||||
romantic_hits.append('Punarphoo')
|
||||
# Venus-to-Moon is annual (~once/year). It does not drive event_class.
|
||||
|
||||
formation_hits: List[str] = []
|
||||
if seventh_lord and seventh_lord in dasha_lords:
|
||||
formation_hits.append('7L_dasha')
|
||||
|
||||
legal_hits: List[str] = []
|
||||
if 'Venus' in dasha_lords:
|
||||
legal_hits.append('Venus_dasha')
|
||||
|
||||
pd_hits: List[str] = []
|
||||
if fifth_lord and fifth_lord in pd_lords:
|
||||
pd_hits.append('5L_pd')
|
||||
if moon_house == 7 and 'Moon' in pd_lords:
|
||||
pd_hits.append('7H_Moon_pd')
|
||||
if seventh_lord and seventh_lord in pd_lords:
|
||||
pd_hits.append('7L_pd')
|
||||
if 'Venus' in pd_lords:
|
||||
pd_hits.append('Venus_pd')
|
||||
|
||||
natal_legal: List[str] = []
|
||||
if darakaraka:
|
||||
natal_legal.append('DK')
|
||||
if ul_sign:
|
||||
natal_legal.append('UL')
|
||||
|
||||
# Timing-only classification. Natal 7H Moon / Punarphoo / DK / UL stay structure flags.
|
||||
# Romantic timing (5L dasha / Moon dasha / Punarphoo return) outranks long 7L MD.
|
||||
# PD never drives event_class.
|
||||
active_classes: List[str] = []
|
||||
if romantic_hits:
|
||||
active_classes.append('romantic_activation')
|
||||
if legal_hits:
|
||||
active_classes.append('legal_marriage')
|
||||
if formation_hits:
|
||||
active_classes.append('relationship_formation')
|
||||
event_class = active_classes[0] if active_classes else None
|
||||
|
||||
return {
|
||||
'event_class': event_class,
|
||||
'romantic_activation': {
|
||||
'lords': list(ROMANTIC_ACTIVATION_LORDS),
|
||||
'hits': romantic_hits,
|
||||
'natal_structure': natal_romantic,
|
||||
'punarphoo': punarphoo,
|
||||
'venus_to_natal_moon': venus_to_moon,
|
||||
},
|
||||
'relationship_formation': {
|
||||
'hits': formation_hits,
|
||||
'seventh_lord': seventh_lord,
|
||||
},
|
||||
'legal_marriage': {
|
||||
'lords': list(LEGAL_MARRIAGE_LORDS),
|
||||
'hits': legal_hits,
|
||||
'natal_structure': natal_legal,
|
||||
},
|
||||
'pd_hits': pd_hits,
|
||||
'active_classes': active_classes,
|
||||
'negative_holdout': {
|
||||
'claim_status': holdout.get('claim_status'),
|
||||
'production_tuning_allowed': holdout.get('production_tuning_allowed'),
|
||||
},
|
||||
'claim_status': 'observation_only',
|
||||
'note': (
|
||||
'心动看 5L/7H月亮/Punarphoo,成对看 7L/DK/UL,法律婚看 Venus/DK/UL。'
|
||||
'Punarphoo 回照 ≠ 结婚;5L 大运 ≠ 法律婚。'
|
||||
'金星过本命月亮每年一次,不点月、不升格心动。不得合并成一句婚恋机会。'
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def analyze_relationship(planets: Dict, asc_sign: str = 'Aries',
|
||||
d9_positions: Dict = None, ul_sign: str = None,
|
||||
darakaraka: str = None, dasha_info: Dict = None) -> Dict:
|
||||
@@ -68,6 +257,7 @@ def analyze_relationship(planets: Dict, asc_sign: str = 'Aries',
|
||||
'strength': [],
|
||||
'challenges': [],
|
||||
'assessment': '',
|
||||
'event_class_split': {},
|
||||
}
|
||||
|
||||
# 1. Venus分析(关系核心指示星)
|
||||
@@ -140,15 +330,27 @@ def analyze_relationship(planets: Dict, asc_sign: str = 'Aries',
|
||||
'note': f'UL={ul_sign} — 社会视角下的伴侣形象和婚姻质量',
|
||||
})
|
||||
|
||||
# 7. 时间窗口
|
||||
# 7. 时间窗口:心动 / 成对 / 法律婚分轨,禁止合并成一句「婚恋机会」
|
||||
event_split = classify_relationship_event_class(
|
||||
planets,
|
||||
asc_sign=asc_sign,
|
||||
dasha_info=dasha_info,
|
||||
ul_sign=ul_sign,
|
||||
darakaraka=darakaraka,
|
||||
)
|
||||
analysis['event_class_split'] = event_split
|
||||
md = ''
|
||||
if dasha_info:
|
||||
md = dasha_info.get('maha_dasha', '')
|
||||
relationship_planets = ['Venus', 'Jupiter', 'Moon']
|
||||
if md in relationship_planets:
|
||||
analysis['timing'].append({
|
||||
'dasha': md,
|
||||
'note': f'当前{md}大运期间,感情/关系相关领域活跃',
|
||||
})
|
||||
md = dasha_info.get('maha_dasha', '') or dasha_info.get('mahadasha', '')
|
||||
event_class = event_split.get('event_class') or 'unclassified'
|
||||
analysis['timing'].append({
|
||||
'dasha': md,
|
||||
'event_class': event_class,
|
||||
'note': (
|
||||
f'当前{md or "未提供"}大运;事件类={event_class}。'
|
||||
'心动看 5L/7H月亮/Punarphoo,成对看 7L/DK/UL,法律婚看 Venus/DK/UL;不得合并。'
|
||||
),
|
||||
})
|
||||
|
||||
# 8. 综合评估
|
||||
venus_strength = 0
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Turn analyze_relationship event_class_split into theme-evidence items.
|
||||
|
||||
Punarphoo / romantic_activation stay observation-only. Legal-marriage items
|
||||
are emitted only when MD/AD hits exist; PD hits never promote that class.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
CLAIM_BOUNDARY = (
|
||||
"romantic_activation is observation-only and is not legal marriage. "
|
||||
"PD hits do not promote an event class. Dasha dates must come from the engine."
|
||||
)
|
||||
|
||||
ThemeEvidence = Callable[..., dict]
|
||||
|
||||
|
||||
def _as_dict(value: Any) -> dict:
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _hits(block: dict) -> list:
|
||||
hits = block.get("hits")
|
||||
return [item for item in hits] if isinstance(hits, list) else []
|
||||
|
||||
|
||||
def _natal(block: dict) -> list:
|
||||
natal = block.get("natal_structure")
|
||||
return [item for item in natal] if isinstance(natal, list) else []
|
||||
|
||||
|
||||
def build_event_class_evidence(relationship: dict, theme_evidence: ThemeEvidence) -> list[dict]:
|
||||
relationship = relationship if isinstance(relationship, dict) else {}
|
||||
split = _as_dict(relationship.get("event_class_split"))
|
||||
if not split:
|
||||
return []
|
||||
|
||||
pd_hits = split.get("pd_hits") if isinstance(split.get("pd_hits"), list) else []
|
||||
items: list[dict] = []
|
||||
|
||||
romantic = _as_dict(split.get("romantic_activation"))
|
||||
if _hits(romantic) or _natal(romantic):
|
||||
items.append(
|
||||
theme_evidence(
|
||||
"Romantic-activation",
|
||||
"Dasha",
|
||||
"心动层(观察):5L / 7宫月亮 / Punarphoo 只说明感情是否被激活,不能写成领证。",
|
||||
"neutral",
|
||||
"weak",
|
||||
source="relationship.event_class_split",
|
||||
details={
|
||||
"event_class": "romantic_activation",
|
||||
"hits": _hits(romantic),
|
||||
"pd_hits": pd_hits,
|
||||
"natal_structure": _natal(romantic),
|
||||
"claim_boundary": CLAIM_BOUNDARY,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
formation = _as_dict(split.get("relationship_formation"))
|
||||
if _hits(formation):
|
||||
items.append(
|
||||
theme_evidence(
|
||||
"Relationship-formation",
|
||||
"Dasha",
|
||||
"成对层:7L / DK / UL 结构进入观察,仍不是法律婚。",
|
||||
"neutral",
|
||||
"moderate",
|
||||
source="relationship.event_class_split",
|
||||
details={
|
||||
"event_class": "relationship_formation",
|
||||
"hits": _hits(formation),
|
||||
"pd_hits": pd_hits,
|
||||
"claim_boundary": CLAIM_BOUNDARY,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
legal = _as_dict(split.get("legal_marriage"))
|
||||
legal_hits = _hits(legal)
|
||||
if legal_hits:
|
||||
items.append(
|
||||
theme_evidence(
|
||||
"Legal-marriage",
|
||||
"Dasha",
|
||||
"法律婚层:仅 MD/AD 命中 Venus / DK / UL 时列出,PD 命中不升格。",
|
||||
"neutral",
|
||||
"moderate",
|
||||
source="relationship.event_class_split",
|
||||
details={
|
||||
"event_class": "legal_marriage",
|
||||
"hits": legal_hits,
|
||||
"pd_hits": pd_hits,
|
||||
"natal_structure": _natal(legal),
|
||||
"claim_boundary": CLAIM_BOUNDARY,
|
||||
},
|
||||
)
|
||||
)
|
||||
return items
|
||||
@@ -88,6 +88,14 @@ CORE_PYTEST_TARGETS = [
|
||||
"tests/test_timing_precision_contract.py",
|
||||
"tests/test_flexible_birth_time_report_contract.py",
|
||||
"tests/test_mcp_strict_workflow_finance.py",
|
||||
"tests/test_mcp_strict_workflow_relationship.py",
|
||||
"tests/test_punarphoo_observation.py",
|
||||
"tests/test_relationship_event_class_evidence.py",
|
||||
"tests/test_full_reading_conditional_dashas.py",
|
||||
"tests/test_capability_evidence_pool.py",
|
||||
"tests/test_readme_badges.py",
|
||||
"tests/test_upstream_import_plan.py",
|
||||
"tests/test_upstream_git_import_b9a0ef8f.py",
|
||||
"tests/test_interpretation_template_registry.py",
|
||||
"tests/test_vedastro_external_technique_evidence.py",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Source-bounded Satabdika (100-year) Mahadasha producer.
|
||||
|
||||
Satabdika is conditional on a vargottama Ascendant. The caller supplies the
|
||||
D9 Ascendant sign explicitly so this module never guesses a divisional method.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from scripts.source_bounded_child_periods import expand_parent_seeded_equal_split_children
|
||||
except ModuleNotFoundError: # pragma: no cover - scripts/ on sys.path
|
||||
from source_bounded_child_periods import expand_parent_seeded_equal_split_children
|
||||
|
||||
|
||||
YEAR_DAYS = 365.25636
|
||||
NAKSHATRA_SPAN = 360.0 / 27.0
|
||||
REVATI_INDEX = 26
|
||||
TOTAL_CYCLE = 100
|
||||
DASHA_SEQUENCE = (
|
||||
("Sun", 5), ("Moon", 5), ("Venus", 10), ("Mercury", 10),
|
||||
("Jupiter", 20), ("Mars", 20), ("Saturn", 30),
|
||||
)
|
||||
_YEARS = dict(DASHA_SEQUENCE)
|
||||
|
||||
|
||||
def _sign_index(longitude: float) -> int:
|
||||
return int(float(longitude) % 360.0 // 30.0)
|
||||
|
||||
|
||||
def satabdika_applicability(*, ascendant_longitude: float, navamsa_ascendant_sign_index: int) -> dict[str, Any]:
|
||||
rasi = _sign_index(ascendant_longitude)
|
||||
navamsa = int(navamsa_ascendant_sign_index) % 12
|
||||
applicable = rasi == navamsa
|
||||
return {
|
||||
"applicable": applicable,
|
||||
"ascendant_rasi_sign_index": rasi,
|
||||
"ascendant_navamsa_sign_index": navamsa,
|
||||
"rule_family": "bphs_satabdika_vargottama_lagna",
|
||||
"reason": "Ascendant is vargottama." if applicable else "Satabdika requires a vargottama Ascendant (same Rasi and Navamsa sign).",
|
||||
}
|
||||
|
||||
|
||||
def _sequence_from(lord: str) -> list[tuple[str, int]]:
|
||||
start = next(index for index, (item, _years) in enumerate(DASHA_SEQUENCE) if item == lord)
|
||||
return list(DASHA_SEQUENCE[start:] + DASHA_SEQUENCE[:start])
|
||||
|
||||
|
||||
def _birth_lord_and_balance(moon_longitude: float) -> tuple[str, float, int]:
|
||||
moon = float(moon_longitude) % 360.0
|
||||
nakshatra = int(moon // NAKSHATRA_SPAN)
|
||||
lord = DASHA_SEQUENCE[((nakshatra - REVATI_INDEX) % 27) % len(DASHA_SEQUENCE)][0]
|
||||
balance = _YEARS[lord] * (1.0 - (moon % NAKSHATRA_SPAN) / NAKSHATRA_SPAN)
|
||||
return lord, balance, nakshatra
|
||||
|
||||
|
||||
def calculate_satabdika_dasha(birth_info: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return birth-forward MD rows; child-period boundaries stay blocked."""
|
||||
required = ("birth_datetime", "ascendant_longitude", "navamsa_ascendant_sign_index", "moon_longitude")
|
||||
missing = [field for field in required if birth_info.get(field) is None]
|
||||
if missing:
|
||||
return {"applicable": False, "execution_status": "blocked", "confidence_status": "blocked", "reason": "Missing required input: " + ", ".join(missing), "major": []}
|
||||
birth = birth_info["birth_datetime"]
|
||||
if isinstance(birth, str):
|
||||
birth = datetime.fromisoformat(birth)
|
||||
applicability = satabdika_applicability(
|
||||
ascendant_longitude=float(birth_info["ascendant_longitude"]),
|
||||
navamsa_ascendant_sign_index=int(birth_info["navamsa_ascendant_sign_index"]),
|
||||
)
|
||||
if not applicability["applicable"]:
|
||||
if birth_info.get("standard_table_profile") == "pl9_reports_all_dasha_tables_v1":
|
||||
applicability = {
|
||||
**applicability,
|
||||
"classical_applicable": False,
|
||||
"applicable": True,
|
||||
"standard_table_profile": "pl9_reports_all_dasha_tables_v1",
|
||||
"applicability_reason": applicability["reason"],
|
||||
"reason": (
|
||||
"PL9 standard-table profile requested: generate the report table while preserving "
|
||||
"the unmet classical applicability gate in the audit fields."
|
||||
),
|
||||
}
|
||||
else:
|
||||
return {**applicability, "execution_status": "not_applicable", "confidence_status": "not_applicable", "total_cycle": TOTAL_CYCLE, "major": []}
|
||||
year_days = float(birth_info.get("dasha_year_days") or YEAR_DAYS)
|
||||
lord, balance, nakshatra = _birth_lord_and_balance(float(birth_info["moon_longitude"]))
|
||||
cursor = birth
|
||||
rows = []
|
||||
for index, (row_lord, full_years) in enumerate(_sequence_from(lord)):
|
||||
years = balance if index == 0 else float(full_years)
|
||||
end = cursor + timedelta(days=years * year_days)
|
||||
rows.append({
|
||||
"level": "mahadasha", "lord": row_lord, "planet": row_lord,
|
||||
"years": years, "full_years": full_years, "is_birth_balance": index == 0,
|
||||
"start_date": cursor.isoformat(timespec="seconds"), "end_date": end.isoformat(timespec="seconds"),
|
||||
})
|
||||
cursor = end
|
||||
child_profile = birth_info.get("child_period_profile")
|
||||
rows, antardasha, pratyantardasha, period_depth_status = expand_parent_seeded_equal_split_children(
|
||||
rows,
|
||||
DASHA_SEQUENCE,
|
||||
profile=str(child_profile) if child_profile else None,
|
||||
)
|
||||
return {
|
||||
**applicability, "execution_status": "executed", "confidence_status": "parameter_sensitive",
|
||||
"verification_status": "source_bounded_local_candidate",
|
||||
"period_depth_status": period_depth_status,
|
||||
"total_cycle": TOTAL_CYCLE, "dasha_year_days": year_days, "moon_nakshatra_index": nakshatra,
|
||||
"starting_lord": lord, "dasha_balance_at_birth_years": balance, "child_period_profile": child_profile or None,
|
||||
"major": rows, "antardasha": antardasha, "pratyantardasha": pratyantardasha,
|
||||
"reason": (
|
||||
"Local source-bounded MD calculation with optional PyJHora-observed child-period profile."
|
||||
if child_profile else
|
||||
"Local source-bounded MD calculation; AD/PD are intentionally blocked pending row-level parity and runtime wiring."
|
||||
),
|
||||
"source_locator": "BPHS conditional Dasha passages: vargottama condition, Revati count, lord order, and 5/5/10/10/20/20/30 year allotments.",
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Source-bounded Shastihayani (60-year) Mahadasha producer.
|
||||
|
||||
This conditional Dasha uses a 28-nakshatra scheme that includes Abhijit and
|
||||
alternates 3-star and 4-star groups. It derives the 28-star group position
|
||||
from Moon longitude when explicit group inputs are not supplied.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from scripts.source_bounded_child_periods import expand_parent_seeded_equal_split_children
|
||||
except ModuleNotFoundError: # pragma: no cover - scripts/ on sys.path
|
||||
from source_bounded_child_periods import expand_parent_seeded_equal_split_children
|
||||
|
||||
|
||||
YEAR_DAYS = 365.25636
|
||||
TOTAL_CYCLE = 60
|
||||
GROUPS = (
|
||||
{"lord": "Jupiter", "years": 10, "size": 3},
|
||||
{"lord": "Sun", "years": 10, "size": 4},
|
||||
{"lord": "Mars", "years": 10, "size": 3},
|
||||
{"lord": "Moon", "years": 6, "size": 4},
|
||||
{"lord": "Mercury", "years": 6, "size": 3},
|
||||
{"lord": "Venus", "years": 6, "size": 4},
|
||||
{"lord": "Saturn", "years": 6, "size": 3},
|
||||
{"lord": "Rahu", "years": 6, "size": 4},
|
||||
)
|
||||
DASHA_SEQUENCE = tuple((str(group["lord"]), int(group["years"])) for group in GROUPS)
|
||||
|
||||
_STAR_SPANS = (
|
||||
("Ashwini", 0.0, 13.3333333333, 0, 0),
|
||||
("Bharani", 13.3333333333, 26.6666666667, 0, 1),
|
||||
("Krittika", 26.6666666667, 40.0, 0, 2),
|
||||
("Rohini", 40.0, 53.3333333333, 1, 0),
|
||||
("Mrigashira", 53.3333333333, 66.6666666667, 1, 1),
|
||||
("Ardra", 66.6666666667, 80.0, 1, 2),
|
||||
("Punarvasu", 80.0, 93.3333333333, 1, 3),
|
||||
("Pushya", 93.3333333333, 106.6666666667, 2, 0),
|
||||
("Ashlesha", 106.6666666667, 120.0, 2, 1),
|
||||
("Magha", 120.0, 133.3333333333, 2, 2),
|
||||
("Purva Phalguni", 133.3333333333, 146.6666666667, 3, 0),
|
||||
("Uttara Phalguni", 146.6666666667, 160.0, 3, 1),
|
||||
("Hasta", 160.0, 173.3333333333, 3, 2),
|
||||
("Chitra", 173.3333333333, 186.6666666667, 3, 3),
|
||||
("Swati", 186.6666666667, 200.0, 4, 0),
|
||||
("Vishakha", 200.0, 213.3333333333, 4, 1),
|
||||
("Anuradha", 213.3333333333, 226.6666666667, 4, 2),
|
||||
("Jyeshtha", 226.6666666667, 240.0, 5, 0),
|
||||
("Mula", 240.0, 253.3333333333, 5, 1),
|
||||
("Purva Ashadha", 253.3333333333, 266.6666666667, 5, 2),
|
||||
("Uttara Ashadha", 266.6666666667, 276.6666666667, 5, 3),
|
||||
("Abhijit", 276.6666666667, 280.8888888889, 6, 0),
|
||||
("Shravana", 280.8888888889, 293.3333333333, 6, 1),
|
||||
("Dhanishta", 293.3333333333, 306.6666666667, 6, 2),
|
||||
("Shatabhisha", 306.6666666667, 320.0, 7, 0),
|
||||
("Purva Bhadrapada", 320.0, 333.3333333333, 7, 1),
|
||||
("Uttara Bhadrapada", 333.3333333333, 346.6666666667, 7, 2),
|
||||
("Revati", 346.6666666667, 360.0, 7, 3),
|
||||
)
|
||||
|
||||
|
||||
def _sign_index(longitude: float) -> int:
|
||||
return int(float(longitude) % 360.0 // 30.0)
|
||||
|
||||
|
||||
def shastihayani_applicability(*, ascendant_longitude: float, sun_longitude: float) -> dict[str, Any]:
|
||||
ascendant_sign = _sign_index(ascendant_longitude)
|
||||
sun_sign = _sign_index(sun_longitude)
|
||||
applicable = ascendant_sign == sun_sign
|
||||
return {
|
||||
"applicable": applicable,
|
||||
"ascendant_sign_index": ascendant_sign,
|
||||
"sun_sign_index": sun_sign,
|
||||
"rule_family": "bphs_shastihayani_sun_in_lagna",
|
||||
"reason": (
|
||||
"Sun is in the Lagna sign."
|
||||
if applicable
|
||||
else "Shastihayani requires Sun in the Lagna sign."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _sequence_from(group_index: int) -> list[dict[str, Any]]:
|
||||
group_index %= len(GROUPS)
|
||||
return list(GROUPS[group_index:] + GROUPS[:group_index])
|
||||
|
||||
|
||||
def resolve_shastihayani_28_position(moon_longitude: float) -> dict[str, Any]:
|
||||
"""Resolve the source-bounded 28-star position used by Shastihayani."""
|
||||
longitude = float(moon_longitude) % 360.0
|
||||
for star_name, start, end, group_index, within_group in _STAR_SPANS:
|
||||
if start <= longitude < end or (end == 360.0 and longitude == 0.0):
|
||||
fraction = (longitude - start) / (end - start)
|
||||
return {
|
||||
"moon_longitude": longitude,
|
||||
"nakshatra": star_name,
|
||||
"span_start_longitude": start,
|
||||
"span_end_longitude": end,
|
||||
"shastihayani_group_index": group_index,
|
||||
"nakshatra_index_within_group": within_group,
|
||||
"nakshatra_fraction_elapsed": fraction,
|
||||
"source_locator": (
|
||||
"Predicting Through Shasti Hayani Dasha: 28 nakshatras including Abhijit; "
|
||||
"Uttara Ashadha curtailed to Capricorn 6°40, Abhijit spans Capricorn "
|
||||
"6°40 to 10°53'20, and Shravana starts from Capricorn 10°53'20."
|
||||
),
|
||||
}
|
||||
raise ValueError("moon_longitude could not be resolved into Shastihayani 28-star spans")
|
||||
|
||||
|
||||
def _birth_lord_and_balance(
|
||||
*, group_index: int, nakshatra_index_within_group: int, nakshatra_fraction_elapsed: float
|
||||
) -> tuple[str, float]:
|
||||
group = GROUPS[int(group_index) % len(GROUPS)]
|
||||
within = int(nakshatra_index_within_group)
|
||||
if not 0 <= within < int(group["size"]):
|
||||
raise ValueError("nakshatra_index_within_group must be inside the selected Shastihayani group")
|
||||
fraction = float(nakshatra_fraction_elapsed)
|
||||
if not 0.0 <= fraction <= 1.0:
|
||||
raise ValueError("nakshatra_fraction_elapsed must be between 0 and 1")
|
||||
years_per_nakshatra = float(group["years"]) / int(group["size"])
|
||||
remaining_current = years_per_nakshatra * (1.0 - fraction)
|
||||
remaining_full_nakshatras = int(group["size"]) - within - 1
|
||||
balance_years = remaining_current + remaining_full_nakshatras * years_per_nakshatra
|
||||
return str(group["lord"]), balance_years
|
||||
|
||||
|
||||
def calculate_shastihayani_dasha(birth_info: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return birth-forward MD rows; child-period boundaries stay blocked."""
|
||||
required = (
|
||||
"birth_datetime",
|
||||
"ascendant_longitude",
|
||||
"sun_longitude",
|
||||
"shastihayani_group_index",
|
||||
"nakshatra_index_within_group",
|
||||
"nakshatra_fraction_elapsed",
|
||||
)
|
||||
if any(birth_info.get(field) is None for field in (
|
||||
"shastihayani_group_index",
|
||||
"nakshatra_index_within_group",
|
||||
"nakshatra_fraction_elapsed",
|
||||
)) and birth_info.get("moon_longitude") is not None:
|
||||
birth_info = {
|
||||
**birth_info,
|
||||
**resolve_shastihayani_28_position(float(birth_info["moon_longitude"])),
|
||||
}
|
||||
missing = [field for field in required if birth_info.get(field) is None]
|
||||
if missing:
|
||||
return {
|
||||
"applicable": False,
|
||||
"execution_status": "blocked",
|
||||
"confidence_status": "blocked",
|
||||
"reason": "Missing required input: " + ", ".join(missing),
|
||||
"major": [],
|
||||
}
|
||||
birth = birth_info["birth_datetime"]
|
||||
if isinstance(birth, str):
|
||||
birth = datetime.fromisoformat(birth)
|
||||
applicability = shastihayani_applicability(
|
||||
ascendant_longitude=float(birth_info["ascendant_longitude"]),
|
||||
sun_longitude=float(birth_info["sun_longitude"]),
|
||||
)
|
||||
standard_table_profile = birth_info.get("standard_table_profile")
|
||||
emit_standard_table = standard_table_profile == "pl9_reports_all_dasha_tables_v1"
|
||||
if not applicability["applicable"] and not emit_standard_table:
|
||||
return {
|
||||
**applicability,
|
||||
"execution_status": "not_applicable",
|
||||
"confidence_status": "not_applicable",
|
||||
"total_cycle": TOTAL_CYCLE,
|
||||
"major": [],
|
||||
}
|
||||
group_index = int(birth_info["shastihayani_group_index"]) % len(GROUPS)
|
||||
try:
|
||||
lord, balance = _birth_lord_and_balance(
|
||||
group_index=group_index,
|
||||
nakshatra_index_within_group=int(birth_info["nakshatra_index_within_group"]),
|
||||
nakshatra_fraction_elapsed=float(birth_info["nakshatra_fraction_elapsed"]),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return {
|
||||
**applicability,
|
||||
"execution_status": "blocked",
|
||||
"confidence_status": "blocked",
|
||||
"total_cycle": TOTAL_CYCLE,
|
||||
"reason": str(exc),
|
||||
"major": [],
|
||||
}
|
||||
year_days = float(birth_info.get("dasha_year_days") or YEAR_DAYS)
|
||||
rows = []
|
||||
cursor = birth
|
||||
for index, group in enumerate(_sequence_from(group_index)):
|
||||
years = balance if index == 0 else float(group["years"])
|
||||
end = cursor + timedelta(days=years * year_days)
|
||||
rows.append(
|
||||
{
|
||||
"level": "mahadasha",
|
||||
"lord": group["lord"],
|
||||
"planet": group["lord"],
|
||||
"years": years,
|
||||
"full_years": group["years"],
|
||||
"is_birth_balance": index == 0,
|
||||
"start_date": cursor.isoformat(timespec="seconds"),
|
||||
"end_date": end.isoformat(timespec="seconds"),
|
||||
}
|
||||
)
|
||||
cursor = end
|
||||
child_profile = birth_info.get("child_period_profile")
|
||||
rows, antardasha, pratyantardasha, period_depth_status = expand_parent_seeded_equal_split_children(
|
||||
rows,
|
||||
DASHA_SEQUENCE,
|
||||
profile=str(child_profile) if child_profile else None,
|
||||
)
|
||||
return {
|
||||
**({**applicability, "applicable": True} if emit_standard_table else applicability),
|
||||
"execution_status": "executed",
|
||||
"confidence_status": "parameter_sensitive",
|
||||
"verification_status": "source_bounded_local_candidate",
|
||||
"classical_applicability": applicability,
|
||||
"standard_table_profile": standard_table_profile or None,
|
||||
"period_depth_status": period_depth_status,
|
||||
"total_cycle": TOTAL_CYCLE,
|
||||
"dasha_year_days": year_days,
|
||||
"starting_lord": lord,
|
||||
"shastihayani_group_index": group_index,
|
||||
"nakshatra_index_within_group": int(birth_info["nakshatra_index_within_group"]),
|
||||
"nakshatra_fraction_elapsed": float(birth_info["nakshatra_fraction_elapsed"]),
|
||||
"dasha_balance_at_birth_years": balance,
|
||||
"child_period_profile": child_profile or None,
|
||||
"major": rows,
|
||||
"antardasha": antardasha,
|
||||
"pratyantardasha": pratyantardasha,
|
||||
"reason": "Local source-bounded calculation with 28-nakshatra input; PL9 standard-table mode may emit rows even when the classical Sun-in-Lagna gate is not met.",
|
||||
"source_locator": "Shastihayani source passages: Sun-in-Lagna gate, 28-nakshatra groups including Abhijit, 3/4 grouping, Jupiter-to-Rahu order, and 10/10/10/6/6/6/6/6 year allotments.",
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Source-bounded Shattrimshatsama (36-year) Mahadasha producer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
|
||||
YEAR_DAYS = 365.25636
|
||||
NAKSHATRA_SPAN = 360.0 / 27.0
|
||||
SRAVANA_INDEX = 21
|
||||
TOTAL_CYCLE = 36
|
||||
DASHA_SEQUENCE = (
|
||||
("Moon", 1),
|
||||
("Sun", 2),
|
||||
("Jupiter", 3),
|
||||
("Mars", 4),
|
||||
("Mercury", 5),
|
||||
("Saturn", 6),
|
||||
("Venus", 7),
|
||||
("Rahu", 8),
|
||||
)
|
||||
_YEARS = dict(DASHA_SEQUENCE)
|
||||
|
||||
|
||||
def shattrimshatsama_applicability(*, birth_is_daytime: bool, ascendant_hora_lord: str) -> dict[str, Any]:
|
||||
"""Apply the day/Sun-Hora or night/Moon-Hora condition."""
|
||||
hora_lord = str(ascendant_hora_lord).strip().title()
|
||||
applicable = (bool(birth_is_daytime) and hora_lord == "Sun") or (
|
||||
not bool(birth_is_daytime) and hora_lord == "Moon"
|
||||
)
|
||||
return {
|
||||
"applicable": applicable,
|
||||
"birth_is_daytime": bool(birth_is_daytime),
|
||||
"ascendant_hora_lord": hora_lord,
|
||||
"rule_family": "bphs_shattrimshatsama_day_sun_hora_or_night_moon_hora",
|
||||
"reason": (
|
||||
"Birth matches the day/Sun-Hora or night/Moon-Hora condition."
|
||||
if applicable
|
||||
else "Shattrimshatsama requires day birth with Sun Hora, or night birth with Moon Hora."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _sequence_from(lord: str) -> list[tuple[str, int]]:
|
||||
start = next(index for index, (item, _years) in enumerate(DASHA_SEQUENCE) if item == lord)
|
||||
return list(DASHA_SEQUENCE[start:] + DASHA_SEQUENCE[:start])
|
||||
|
||||
|
||||
def _birth_lord_and_balance(moon_longitude: float) -> tuple[str, float, int]:
|
||||
moon = float(moon_longitude) % 360.0
|
||||
nakshatra = int(moon // NAKSHATRA_SPAN)
|
||||
lord = DASHA_SEQUENCE[((nakshatra - SRAVANA_INDEX) % 27) % len(DASHA_SEQUENCE)][0]
|
||||
balance = _YEARS[lord] * (1.0 - (moon % NAKSHATRA_SPAN) / NAKSHATRA_SPAN)
|
||||
return lord, balance, nakshatra
|
||||
|
||||
|
||||
def calculate_shattrimshatsama_dasha(birth_info: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return birth-forward MD rows; child-period boundaries stay blocked."""
|
||||
required = ("birth_datetime", "birth_is_daytime", "ascendant_hora_lord", "moon_longitude")
|
||||
missing = [field for field in required if birth_info.get(field) is None]
|
||||
if missing:
|
||||
return {
|
||||
"applicable": False,
|
||||
"execution_status": "blocked",
|
||||
"confidence_status": "blocked",
|
||||
"reason": "Missing required input: " + ", ".join(missing),
|
||||
"major": [],
|
||||
}
|
||||
birth = birth_info["birth_datetime"]
|
||||
if isinstance(birth, str):
|
||||
birth = datetime.fromisoformat(birth)
|
||||
applicability = shattrimshatsama_applicability(
|
||||
birth_is_daytime=bool(birth_info["birth_is_daytime"]),
|
||||
ascendant_hora_lord=str(birth_info["ascendant_hora_lord"]),
|
||||
)
|
||||
if not applicability["applicable"]:
|
||||
return {
|
||||
**applicability,
|
||||
"execution_status": "not_applicable",
|
||||
"confidence_status": "not_applicable",
|
||||
"total_cycle": TOTAL_CYCLE,
|
||||
"major": [],
|
||||
}
|
||||
year_days = float(birth_info.get("dasha_year_days") or YEAR_DAYS)
|
||||
lord, balance, nakshatra = _birth_lord_and_balance(float(birth_info["moon_longitude"]))
|
||||
cursor = birth
|
||||
rows = []
|
||||
for index, (row_lord, full_years) in enumerate(_sequence_from(lord)):
|
||||
years = balance if index == 0 else float(full_years)
|
||||
end = cursor + timedelta(days=years * year_days)
|
||||
rows.append(
|
||||
{
|
||||
"level": "mahadasha",
|
||||
"lord": row_lord,
|
||||
"planet": row_lord,
|
||||
"years": years,
|
||||
"full_years": full_years,
|
||||
"is_birth_balance": index == 0,
|
||||
"start_date": cursor.isoformat(timespec="seconds"),
|
||||
"end_date": end.isoformat(timespec="seconds"),
|
||||
}
|
||||
)
|
||||
cursor = end
|
||||
return {
|
||||
**applicability,
|
||||
"execution_status": "executed",
|
||||
"confidence_status": "parameter_sensitive",
|
||||
"verification_status": "source_bounded_local_candidate",
|
||||
"period_depth_status": "mahadasha_executed_ad_pd_blocked_pending_row_level_replay",
|
||||
"total_cycle": TOTAL_CYCLE,
|
||||
"dasha_year_days": year_days,
|
||||
"moon_nakshatra_index": nakshatra,
|
||||
"starting_lord": lord,
|
||||
"dasha_balance_at_birth_years": balance,
|
||||
"major": rows,
|
||||
"reason": "Local source-bounded MD calculation; AD/PD are intentionally blocked pending row-level parity and runtime wiring.",
|
||||
"source_locator": "BPHS conditional Dasha passages: day/Sun-Hora or night/Moon-Hora gate, Sravana count, Moon-to-Rahu order, and 1-8 year allotments.",
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Independent Shodashottari Dasha producer, bounded to a published BPHS rule.
|
||||
|
||||
Source basis: BPHS conditional-Dasha passages reproduced in the public
|
||||
``bphs.pdf`` locator and the conditional-Dasha reference packet. The local
|
||||
formula is independently expressed here; PyJHora is used only as an external
|
||||
observation in the separate raw-probe ledger.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from scripts.source_bounded_child_periods import expand_parent_seeded_equal_split_children
|
||||
except ModuleNotFoundError: # pragma: no cover - scripts/ on sys.path
|
||||
from source_bounded_child_periods import expand_parent_seeded_equal_split_children
|
||||
|
||||
|
||||
YEAR_DAYS = 365.25636
|
||||
NAKSHATRA_SPAN = 360.0 / 27.0
|
||||
PUSHYA_INDEX = 7
|
||||
TOTAL_CYCLE = 116
|
||||
DASHA_SEQUENCE = (
|
||||
("Sun", 11),
|
||||
("Mars", 12),
|
||||
("Jupiter", 13),
|
||||
("Saturn", 14),
|
||||
("Ketu", 15),
|
||||
("Moon", 16),
|
||||
("Mercury", 17),
|
||||
("Venus", 18),
|
||||
)
|
||||
_YEARS_BY_LORD = dict(DASHA_SEQUENCE)
|
||||
|
||||
|
||||
def _sign_index(longitude: float) -> int:
|
||||
return int(float(longitude) % 360.0 // 30.0)
|
||||
|
||||
|
||||
def _hora_lord(ascendant_longitude: float) -> str:
|
||||
"""Return Parashari hora lord for the ascendant's 15-degree half-sign."""
|
||||
sign = _sign_index(ascendant_longitude)
|
||||
first_half = float(ascendant_longitude) % 30.0 < 15.0
|
||||
odd_sign = sign % 2 == 0 # Aries is index 0.
|
||||
if odd_sign:
|
||||
return "Sun" if first_half else "Moon"
|
||||
return "Moon" if first_half else "Sun"
|
||||
|
||||
|
||||
def _paksha(sun_longitude: float, moon_longitude: float) -> str:
|
||||
return "shukla" if (float(moon_longitude) - float(sun_longitude)) % 360.0 < 180.0 else "krishna"
|
||||
|
||||
|
||||
def shodashottari_applicability(*, ascendant_longitude: float, sun_longitude: float, moon_longitude: float) -> dict[str, Any]:
|
||||
"""Apply the published Shodashottari hora-plus-paksha gate."""
|
||||
hora_lord = _hora_lord(ascendant_longitude)
|
||||
paksha = _paksha(sun_longitude, moon_longitude)
|
||||
applicable = (hora_lord == "Sun" and paksha == "shukla") or (hora_lord == "Moon" and paksha == "krishna")
|
||||
return {
|
||||
"applicable": applicable,
|
||||
"hora_lord": hora_lord,
|
||||
"paksha": paksha,
|
||||
"rule_family": "bphs_shodashottari_hora_paksha",
|
||||
"reason": (
|
||||
"Ascendant is in Sun hora during Shukla paksha."
|
||||
if hora_lord == "Sun" and paksha == "shukla"
|
||||
else "Ascendant is in Moon hora during Krishna paksha."
|
||||
if hora_lord == "Moon" and paksha == "krishna"
|
||||
else "The Shodashottari hora-plus-paksha applicability gate is not met."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _sequence_from(starting_lord: str) -> list[tuple[str, int]]:
|
||||
start_index = next(index for index, (lord, _years) in enumerate(DASHA_SEQUENCE) if lord == starting_lord)
|
||||
return list(DASHA_SEQUENCE[start_index:] + DASHA_SEQUENCE[:start_index])
|
||||
|
||||
|
||||
def _birth_lord_and_balance(moon_longitude: float) -> tuple[str, float, int]:
|
||||
moon_longitude = float(moon_longitude) % 360.0
|
||||
nakshatra_index = int(moon_longitude // NAKSHATRA_SPAN)
|
||||
offset = (nakshatra_index - PUSHYA_INDEX) % 27
|
||||
starting_lord = DASHA_SEQUENCE[offset % len(DASHA_SEQUENCE)][0]
|
||||
fraction_elapsed = (moon_longitude % NAKSHATRA_SPAN) / NAKSHATRA_SPAN
|
||||
remaining_years = _YEARS_BY_LORD[starting_lord] * (1.0 - fraction_elapsed)
|
||||
return starting_lord, remaining_years, nakshatra_index
|
||||
|
||||
|
||||
def _iso(value: datetime) -> str:
|
||||
return value.isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def calculate_shodashottari_dasha(birth_info: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Calculate birth-forward Shodashottari MD; keep AD/PD explicitly unclosed."""
|
||||
required = ("birth_datetime", "ascendant_longitude", "sun_longitude", "moon_longitude")
|
||||
missing = [field for field in required if birth_info.get(field) is None]
|
||||
if missing:
|
||||
return {
|
||||
"applicable": False,
|
||||
"execution_status": "blocked",
|
||||
"confidence_status": "blocked",
|
||||
"reason": "Missing required input: " + ", ".join(missing),
|
||||
"major": [],
|
||||
}
|
||||
birth_dt = birth_info["birth_datetime"]
|
||||
if isinstance(birth_dt, str):
|
||||
birth_dt = datetime.fromisoformat(birth_dt)
|
||||
applicability = shodashottari_applicability(
|
||||
ascendant_longitude=float(birth_info["ascendant_longitude"]),
|
||||
sun_longitude=float(birth_info["sun_longitude"]),
|
||||
moon_longitude=float(birth_info["moon_longitude"]),
|
||||
)
|
||||
standard_table_profile = birth_info.get("standard_table_profile") == "pl9_reports_all_dasha_tables_v1"
|
||||
if not applicability["applicable"]:
|
||||
if standard_table_profile:
|
||||
applicability = {
|
||||
**applicability,
|
||||
"classical_applicable": False,
|
||||
"applicable": True,
|
||||
"standard_table_profile": "pl9_reports_all_dasha_tables_v1",
|
||||
"applicability_reason": applicability["reason"],
|
||||
"reason": (
|
||||
"PL9 standard-table profile requested: generate the report table while preserving "
|
||||
"the unmet classical applicability gate in the audit fields."
|
||||
),
|
||||
}
|
||||
else:
|
||||
return {
|
||||
**applicability,
|
||||
"execution_status": "not_applicable",
|
||||
"confidence_status": "not_applicable",
|
||||
"total_cycle": TOTAL_CYCLE,
|
||||
"major": [],
|
||||
"reason": applicability["reason"],
|
||||
}
|
||||
year_days = float(birth_info.get("dasha_year_days") or YEAR_DAYS)
|
||||
starting_lord, balance_years, moon_nakshatra_index = _birth_lord_and_balance(float(birth_info["moon_longitude"]))
|
||||
periods = []
|
||||
cursor = birth_dt
|
||||
for index, (lord, full_years) in enumerate(_sequence_from(starting_lord)):
|
||||
years = balance_years if index == 0 else float(full_years)
|
||||
end = cursor + timedelta(days=years * year_days)
|
||||
period = {
|
||||
"level": "mahadasha",
|
||||
"planet": lord,
|
||||
"lord": lord,
|
||||
"years": years,
|
||||
"full_years": full_years,
|
||||
"is_birth_balance": index == 0,
|
||||
"start_date": _iso(cursor),
|
||||
"end_date": _iso(end),
|
||||
}
|
||||
periods.append(period)
|
||||
cursor = end
|
||||
child_profile = birth_info.get("child_period_profile")
|
||||
periods, antardasha, pratyantardasha, period_depth_status = expand_parent_seeded_equal_split_children(
|
||||
periods,
|
||||
DASHA_SEQUENCE,
|
||||
profile=str(child_profile) if child_profile else None,
|
||||
)
|
||||
return {
|
||||
**applicability,
|
||||
"execution_status": "executed",
|
||||
"confidence_status": "parameter_sensitive",
|
||||
"verification_status": "source_bounded_local_candidate",
|
||||
"period_depth_status": period_depth_status,
|
||||
"total_cycle": TOTAL_CYCLE,
|
||||
"dasha_year_days": year_days,
|
||||
"moon_nakshatra_index": moon_nakshatra_index,
|
||||
"starting_lord": starting_lord,
|
||||
"dasha_balance_at_birth_years": balance_years,
|
||||
"child_period_profile": child_profile or None,
|
||||
"major": periods,
|
||||
"antardasha": antardasha,
|
||||
"pratyantardasha": pratyantardasha,
|
||||
"reason": (
|
||||
"Local source-bounded MD calculation with optional PyJHora-observed child-period profile."
|
||||
if child_profile else
|
||||
"Local source-bounded MD calculation; AD/PD are intentionally blocked pending row-level parity and runtime wiring."
|
||||
),
|
||||
"source_locator": "BPHS conditional Dasha passages: Shodashottari applicability, Pushya count, lord order, and 11-18 year allotments.",
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Optional child-period expanders for source-bounded Dasha producers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
|
||||
PYJHORA_PARENT_SEEDED_EQUAL_SPLIT_PROFILE = "pyjhora_parent_seeded_equal_split_observed_v1"
|
||||
|
||||
|
||||
def _parse_dt(value: Any) -> datetime:
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
return datetime.fromisoformat(str(value))
|
||||
|
||||
|
||||
def _iso(value: datetime) -> str:
|
||||
return value.isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _sequence_from(lord: str, sequence: tuple[tuple[str, int], ...]) -> list[tuple[str, int]]:
|
||||
start = next(index for index, (item, _years) in enumerate(sequence) if item == lord)
|
||||
return list(sequence[start:] + sequence[:start])
|
||||
|
||||
|
||||
def expand_parent_seeded_equal_split_children(
|
||||
major_rows: list[dict[str, Any]],
|
||||
sequence: tuple[tuple[str, int], ...],
|
||||
*,
|
||||
profile: str | None,
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]], str]:
|
||||
"""Attach PyJHora-observed equal-split AD/PD rows when explicitly selected."""
|
||||
rows = deepcopy(major_rows)
|
||||
if profile in (None, "", "blocked"):
|
||||
return rows, [], [], "mahadasha_executed_ad_pd_blocked_pending_row_level_replay"
|
||||
if profile != PYJHORA_PARENT_SEEDED_EQUAL_SPLIT_PROFILE:
|
||||
return rows, [], [], "mahadasha_executed_child_period_profile_unknown_blocked"
|
||||
|
||||
child_count = len(sequence)
|
||||
all_ad: list[dict[str, Any]] = []
|
||||
all_pd: list[dict[str, Any]] = []
|
||||
for md in rows:
|
||||
md_lord = str(md.get("lord") or md.get("planet"))
|
||||
md_start = _parse_dt(md["start_date"])
|
||||
md_end = _parse_dt(md["end_date"])
|
||||
md_seconds = (md_end - md_start).total_seconds()
|
||||
ad_seconds = md_seconds / child_count
|
||||
ad_rows: list[dict[str, Any]] = []
|
||||
ad_cursor = md_start
|
||||
for ad_index, (ad_lord, _ad_years) in enumerate(_sequence_from(md_lord, sequence)):
|
||||
ad_end = md_end if ad_index == child_count - 1 else ad_cursor + timedelta(seconds=ad_seconds)
|
||||
pd_seconds = (ad_end - ad_cursor).total_seconds() / child_count
|
||||
pd_rows: list[dict[str, Any]] = []
|
||||
pd_cursor = ad_cursor
|
||||
for pd_index, (pd_lord, _pd_years) in enumerate(_sequence_from(ad_lord, sequence)):
|
||||
pd_end = ad_end if pd_index == child_count - 1 else pd_cursor + timedelta(seconds=pd_seconds)
|
||||
pd_row = {
|
||||
"level": "pratyantardasha",
|
||||
"lord": pd_lord,
|
||||
"planet": pd_lord,
|
||||
"parent_mahadasha": md_lord,
|
||||
"parent_antardasha": ad_lord,
|
||||
"start_date": _iso(pd_cursor),
|
||||
"end_date": _iso(pd_end),
|
||||
"child_period_profile": profile,
|
||||
"derivation": "pyjhora_observed_parent_seeded_equal_split",
|
||||
}
|
||||
pd_rows.append(pd_row)
|
||||
all_pd.append(pd_row)
|
||||
pd_cursor = pd_end
|
||||
ad_row = {
|
||||
"level": "antardasha",
|
||||
"lord": ad_lord,
|
||||
"planet": ad_lord,
|
||||
"parent_mahadasha": md_lord,
|
||||
"start_date": _iso(ad_cursor),
|
||||
"end_date": _iso(ad_end),
|
||||
"child_period_profile": profile,
|
||||
"derivation": "pyjhora_observed_parent_seeded_equal_split",
|
||||
"pratyantardasha": pd_rows,
|
||||
}
|
||||
ad_rows.append(ad_row)
|
||||
all_ad.append(ad_row)
|
||||
ad_cursor = ad_end
|
||||
md["antardasha"] = ad_rows
|
||||
return rows, all_ad, all_pd, "mahadasha_antardasha_pratyantardasha_executed_pyjhora_observed_profile"
|
||||
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Source-bounded Tribhagi Dasha producer.
|
||||
|
||||
Tribhagi divides each Vimshottari lord's years across its three nakshatras.
|
||||
The full 27-nakshatra traversal is 120 years; each nine-nakshatra bhaga is
|
||||
40 years. This module emits birth-forward major rows only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
|
||||
YEAR_DAYS = 365.25636
|
||||
NAKSHATRA_SPAN = 360.0 / 27.0
|
||||
VIMSHOTTARI_SEQUENCE = (
|
||||
("Ketu", 7),
|
||||
("Venus", 20),
|
||||
("Sun", 6),
|
||||
("Moon", 10),
|
||||
("Mars", 7),
|
||||
("Rahu", 18),
|
||||
("Jupiter", 16),
|
||||
("Saturn", 19),
|
||||
("Mercury", 17),
|
||||
)
|
||||
NAKSHATRA_NAMES = (
|
||||
"Ashwini",
|
||||
"Bharani",
|
||||
"Krittika",
|
||||
"Rohini",
|
||||
"Mrigashira",
|
||||
"Ardra",
|
||||
"Punarvasu",
|
||||
"Pushya",
|
||||
"Ashlesha",
|
||||
"Magha",
|
||||
"Purva Phalguni",
|
||||
"Uttara Phalguni",
|
||||
"Hasta",
|
||||
"Chitra",
|
||||
"Swati",
|
||||
"Vishakha",
|
||||
"Anuradha",
|
||||
"Jyeshtha",
|
||||
"Mula",
|
||||
"Purva Ashadha",
|
||||
"Uttara Ashadha",
|
||||
"Shravana",
|
||||
"Dhanishtha",
|
||||
"Shatabhisha",
|
||||
"Purva Bhadrapada",
|
||||
"Uttara Bhadrapada",
|
||||
"Revati",
|
||||
)
|
||||
FULL_CYCLE_YEARS = 120
|
||||
BHAGA_CYCLE_YEARS = 40
|
||||
|
||||
|
||||
def _nakshatra_index(moon_longitude: float) -> int:
|
||||
return int(float(moon_longitude) % 360.0 // NAKSHATRA_SPAN)
|
||||
|
||||
|
||||
def _nakshatra_fraction_elapsed(moon_longitude: float) -> float:
|
||||
return (float(moon_longitude) % NAKSHATRA_SPAN) / NAKSHATRA_SPAN
|
||||
|
||||
|
||||
def _lord_for_nakshatra(index: int) -> tuple[str, float]:
|
||||
lord, years = VIMSHOTTARI_SEQUENCE[index % len(VIMSHOTTARI_SEQUENCE)]
|
||||
return lord, years / 3.0
|
||||
|
||||
|
||||
def _cycle_indices(start: int, mode: str) -> list[int]:
|
||||
if mode == "full_120":
|
||||
return [(start + offset) % 27 for offset in range(27)]
|
||||
if mode == "single_bhaga_40":
|
||||
bhaga_start = (start // 9) * 9
|
||||
return [(start + offset - bhaga_start) % 9 + bhaga_start for offset in range(9)]
|
||||
raise ValueError("mode must be full_120 or single_bhaga_40")
|
||||
|
||||
|
||||
def calculate_tribhagi_dasha(birth_info: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return birth-forward Tribhagi rows; child-period boundaries stay blocked."""
|
||||
required = ("birth_datetime", "moon_longitude")
|
||||
missing = [field for field in required if birth_info.get(field) is None]
|
||||
if missing:
|
||||
return {
|
||||
"applicable": False,
|
||||
"execution_status": "blocked",
|
||||
"confidence_status": "blocked",
|
||||
"reason": "Missing required input: " + ", ".join(missing),
|
||||
"major": [],
|
||||
}
|
||||
mode = str(birth_info.get("mode") or "full_120")
|
||||
try:
|
||||
indices = _cycle_indices(_nakshatra_index(float(birth_info["moon_longitude"])), mode)
|
||||
except ValueError as exc:
|
||||
return {
|
||||
"applicable": False,
|
||||
"execution_status": "blocked",
|
||||
"confidence_status": "blocked",
|
||||
"reason": str(exc),
|
||||
"major": [],
|
||||
}
|
||||
birth = birth_info["birth_datetime"]
|
||||
if isinstance(birth, str):
|
||||
birth = datetime.fromisoformat(birth)
|
||||
year_days = float(birth_info.get("dasha_year_days") or YEAR_DAYS)
|
||||
fraction_elapsed = _nakshatra_fraction_elapsed(float(birth_info["moon_longitude"]))
|
||||
rows = []
|
||||
cursor = birth
|
||||
for offset, nakshatra_index in enumerate(indices):
|
||||
lord, full_years = _lord_for_nakshatra(nakshatra_index)
|
||||
years = full_years * (1.0 - fraction_elapsed) if offset == 0 else full_years
|
||||
end = cursor + timedelta(days=years * year_days)
|
||||
rows.append(
|
||||
{
|
||||
"level": "mahadasha",
|
||||
"lord": lord,
|
||||
"planet": lord,
|
||||
"nakshatra_index": nakshatra_index,
|
||||
"nakshatra": NAKSHATRA_NAMES[nakshatra_index],
|
||||
"years": years,
|
||||
"full_years": full_years,
|
||||
"is_birth_balance": offset == 0,
|
||||
"start_date": cursor.isoformat(timespec="seconds"),
|
||||
"end_date": end.isoformat(timespec="seconds"),
|
||||
}
|
||||
)
|
||||
cursor = end
|
||||
return {
|
||||
"schema": "tribhagi_dasha_v1",
|
||||
"applicable": True,
|
||||
"execution_status": "executed",
|
||||
"confidence_status": "parameter_sensitive",
|
||||
"verification_status": "source_bounded_local_candidate",
|
||||
"period_depth_status": "mahadasha_executed_ad_pd_blocked_pending_row_level_replay",
|
||||
"mode": mode,
|
||||
"total_cycle": FULL_CYCLE_YEARS if mode == "full_120" else BHAGA_CYCLE_YEARS,
|
||||
"dasha_year_days": year_days,
|
||||
"starting_nakshatra_index": indices[0],
|
||||
"starting_lord": rows[0]["lord"] if rows else None,
|
||||
"dasha_balance_at_birth_years": rows[0]["years"] if rows else 0.0,
|
||||
"major": rows,
|
||||
"reason": "Local source-bounded Tribhagi MD calculation; AD/PD and PL9 semantic mapping remain blocked pending row-level replay.",
|
||||
"source_locator": "Public Tribhagi explanations: Vimshottari lord years divided across three ruled nakshatras; full 120-year cycle or single 40-year bhaga view.",
|
||||
}
|
||||
Reference in New Issue
Block a user