fix(relationship): freeze marriage event classes and add conditional dashas (BUG-608)
Independent Staging Quality Gate / validate (push) Successful in 11m26s
Independent Staging Quality Gate / publish (push) Successful in 13m24s

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:
Jesse_Chen
2026-09-09 16:04:52 +08:00
co-authored by Cursor
parent 164535ab22
commit 04ad3325d5
1209 changed files with 464511 additions and 90 deletions
+149
View File
@@ -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.",
}
+11
View File
@@ -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",
+37 -17
View File
@@ -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:
+207
View File
@@ -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.",
}
+147
View File
@@ -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.",
}
+5 -1
View File
@@ -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
View File
@@ -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,
+123
View File
@@ -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.",
}
+166
View File
@@ -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.",
}
+102
View File
@@ -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,
}
+211 -9
View File
@@ -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
+8
View File
@@ -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",
]
+120
View File
@@ -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.",
}
+239
View File
@@ -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.",
}
+120
View File
@@ -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.",
}
+184
View File
@@ -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.",
}
+88
View File
@@ -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"
+148
View File
@@ -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.",
}