Files
Jyotisha/scripts/kp_system.py
T
Jesse_Chen f224146348
Independent Staging Quality Gate / validate (push) Has been cancelled
Independent Staging Quality Gate / publish (push) Has been cancelled
feat(upstream): merge a6f47abd engine, MCP, and orchestrator
Three-way-merge calculation modules and pl9-export into the product fork while keeping commercial API routes, Raman ayanamsa, and the consultation contract as a keypath superset.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-03 20:28:57 +08:00

683 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
KP (Krishnamurti Paddhati) 占星系统模块
基于 diliprk/VedicAstro (MIT License) 核心算法适配
核心功能:
1. Sublord/Subsublord 计算(基于Vimshottari比例划分)
2. Planet Significator ABCD体系
3. House Significator ABCD体系
"""
import json
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Dict, List, Tuple, Optional
SIGNS = ['Aries', 'Taurus', 'Gemini', 'Cancer', 'Leo', 'Virgo',
'Libra', 'Scorpio', 'Sagittarius', 'Capricorn', 'Aquarius', 'Pisces']
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'
}
NAKSHATRAS = [
'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', 'Dhanishta', 'Shatabhisha',
'Purva Bhadrapada', 'Uttara Bhadrapada', 'Revati'
]
# Vimshottari年限(KP系统使用相同比例划分sublord)
VIMSHOTTARI_DURATION = [7, 20, 6, 10, 7, 18, 16, 19, 17]
KP_LORDS = ["Ketu", "Venus", "Sun", "Moon", "Mars", "Rahu", "Jupiter", "Saturn", "Mercury"]
STAR_LORDS = KP_LORDS * 3 # 27 Nakshatras = 3 cycles of 9 lords
VIMSHOTTARI_YEARS = dict(zip(KP_LORDS, VIMSHOTTARI_DURATION))
NAKSHATRA_SPAN = 360.0 / 27.0 # 13.333... degrees
ROOT = Path(__file__).resolve().parents[1]
def _load_json_artifact(relative_path: str) -> Dict[str, Any]:
path = ROOT / relative_path
with open(path, encoding='utf-8') as f:
data = json.load(f)
data.setdefault('artifact_path', relative_path)
return data
def kp_maturity_profile() -> Dict[str, Any]:
"""Return the current KP maturity boundary for reports and API consumers.
This intentionally reuses pinned oracle/status packets instead of inventing
a second truth policy inside runtime code. It is a display/report guard:
KP layers can be shown, but prediction truth stays blocked until the status
packets say the numeric oracle and independent holdout gates are closed.
"""
event = _load_json_artifact('references/oracle/kp_exact_cusp_mainline_status_2026_08_22.json')
gate = _load_json_artifact('references/oracle/kp_exact_cusp_closure_dashboard_2026_08_22.json')
replay = _load_json_artifact('references/oracle/kp_real_event_replay_gate_2026_07_30.json')
cusp = _load_json_artifact('references/oracle/kp_12_cusp_numeric_oracle_readiness_2026_07_23.json')
table = _load_json_artifact('references/oracle/kp_external_table_hash_manifest_2026_07_20.json')
workflow_gate = _load_json_artifact('references/oracle/kp_significator_workflow_gate_2026_07_23.json')
runtime_evidence_layers = workflow_gate.get('runtime_evidence_layers') or []
remaining_hard_reasons = ((event.get('promotion_gate') or {}).get('remaining_hard_reasons')) or []
holdout_counts = replay.get('current_holdout_counts') or {}
required_counts = replay.get('required_holdout_counts') or {}
closed = (
event.get('truth_matrix_allowed') is True
and event.get('timing_truth_promoted') is True
and replay.get('timing_truth_promoted') is True
and (holdout_counts.get('frozen_positive_count') or 0) >= (required_counts.get('minimum_frozen_positive') or 20)
and (holdout_counts.get('frozen_negative_count') or 0) >= (required_counts.get('minimum_frozen_negative') or 80)
)
if closed:
claim_status = 'timing_truth_closed'
display_policy = 'verified_prediction_allowed_with_evidence'
else:
claim_status = 'observation_only_truth_blocked'
display_policy = 'show_kp_layers_as_research_evidence_only_do_not_claim_precise_timing'
blockers = []
blockers.extend([row.get('blocker') for row in (event.get('remaining_blockers') or []) if isinstance(row, dict)])
blockers.extend(replay.get('maturity_gap') or [])
blockers.extend(cusp.get('remaining_blockers') or [])
blockers = list(dict.fromkeys(str(item) for item in blockers if item))
return {
'scope': 'kp_maturity_profile',
'claim_status': claim_status,
'display_policy': display_policy,
'timing_truth_promoted': closed,
'truth_matrix_allowed': closed,
'production_tuning_allowed': closed,
'source_artifacts': {
'event_closure_status': event['artifact_path'],
'event_closure_dashboard': gate['artifact_path'],
'real_event_replay_gate': replay['artifact_path'],
'cusp_numeric_oracle_readiness': cusp['artifact_path'],
'external_table_hash_manifest': table['artifact_path'],
'significator_workflow_gate': workflow_gate['artifact_path'],
},
'runtime_evidence_layers': runtime_evidence_layers,
'runtime_evidence_layer_count': len(runtime_evidence_layers),
'remaining_hard_reasons': remaining_hard_reasons,
'remaining_hard_reason_count': len(remaining_hard_reasons),
'selected_lane': ((event.get('selector') or {}).get('selected_lane')),
'fallback_lane': ((event.get('selector') or {}).get('fallback_lane')),
'promotion_gate_passed': bool((event.get('promotion_gate') or {}).get('gate_passed', False)),
'closed_numeric_assets': {
'kp_sub_lord_fixture_hash_fixed': table.get('status') == 'fixed_hash' and table.get('row_count') == 249,
'kp_sub_lord_midpoint_all_249_guarded': True,
'kp_sub_lord_249_segment_parity_closed': table.get('status') == 'fixed_hash' and table.get('row_count') == 249,
'public_12_cusp_packet_ready': bool(cusp.get('ready_evidence')),
},
'closed_oracle_parity_assets': {
'kp_sub_lord_249_segment_parity': {
'status': 'closed',
'scope': 'structure_only_not_event_timing',
'source_artifact': table['artifact_path'],
'test_guard': 'tests/test_kp_system.py::test_kp_sublord_matches_vedicastro_csv_all_249_segments',
'boundary': 'This closes the 249 SubLord segmentation/table parity only; it does not close cusp, ruling-planet, significator workflow, DBA, real-event, or timing-outcome truth.',
},
},
'holdout_counts': holdout_counts,
'required_holdout_counts': required_counts,
'remaining_blockers': blockers,
'claim_boundary': (
'KP star/sub/sub-sub/significator/DBA layers may be displayed in the personal report, '
'but exact event timing remains blocked until numeric oracle settings, ruling planets, '
'and independent 20 positive / 80 negative holdout replay are closed.'
),
}
def build_kp_western_support_surface(
western_support: Optional[Dict[str, Any]] = None,
*,
maturity_profile: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""Normalize Western support for KP report-facing surfaces only.
This packages support/convergence/negative-evidence summaries without
changing the underlying KP maturity boundary.
"""
maturity = maturity_profile or kp_maturity_profile()
source = western_support if isinstance(western_support, dict) else {}
convergence_source = source.get('convergence') if isinstance(source.get('convergence'), dict) else {}
negative_source = source.get('negative_evidence') if isinstance(source.get('negative_evidence'), dict) else {}
shared_signal_count = convergence_source.get('shared_signal_count')
shared_signal_count = shared_signal_count if isinstance(shared_signal_count, int) else 0
conflict_count = convergence_source.get('conflict_count')
conflict_count = conflict_count if isinstance(conflict_count, int) else 0
missing_layers = negative_source.get('missing_layers')
missing_layers = [str(item) for item in missing_layers] if isinstance(missing_layers, list) else []
rejected_windows = negative_source.get('rejected_windows')
rejected_windows = [str(item) for item in rejected_windows] if isinstance(rejected_windows, list) else []
convergence_status = str(convergence_source.get('status') or ('not_provided' if not source else 'partial'))
negative_status = str(negative_source.get('status') or ('not_provided' if not source else 'partial'))
if not source:
status = 'not_provided'
elif 'blocked' in {convergence_status, negative_status}:
status = 'blocked'
elif 'partial' in {convergence_status, negative_status}:
status = 'partial'
elif convergence_status == negative_status == 'used':
status = 'used'
else:
status = 'partial'
convergence_summary = convergence_source.get('summary')
if not convergence_summary:
convergence_summary = f'{shared_signal_count} shared signals; {conflict_count} conflicts'
negative_summary = negative_source.get('summary')
if not negative_summary:
negative_summary = f'{len(missing_layers)} missing layers; {len(rejected_windows)} rejected windows'
return {
'status': status,
'convergence': {
'status': convergence_status,
'shared_signal_count': shared_signal_count,
'conflict_count': conflict_count,
'summary': str(convergence_summary),
},
'negative_evidence': {
'status': negative_status,
'missing_layers': missing_layers,
'rejected_windows': rejected_windows,
'summary': str(negative_summary),
},
'claim_boundary': (
'Western support remains a KP support layer only; it cannot upgrade blocked KP timing truth '
'or replace Jyotish-first adjudication.'
),
'maturity_claim_status': maturity.get('claim_status'),
'truth_matrix_allowed': maturity.get('truth_matrix_allowed') is True,
}
def get_kp_lords(degree: float) -> Dict:
"""
KP Sublord/Subsublord 计算核心(基于 diliprk/VedicAstro MIT 算法)。
输入任意黄道经度,返回:
- Rasi Lord: 星座主星
- Nakshatra: 星宿名称
- Nakshatra Lord: 星宿主星
- Nakshatra Pada: 星宿四分之一
- Sub Lord: 子主星(KP特有,按Vimshottari比例划分)
- Sub Sub Lord: 次子主星(KP特有,进一步细分)
Args:
degree: 黄道经度(0-360
Returns:
KP lords字典
"""
deg = degree % 360
# 1. Sign lord
sign_index = int(deg // 30)
# 2. Nakshatra
nakshatra_index = int(deg // NAKSHATRA_SPAN) % 27
nakshatra_deg = deg % NAKSHATRA_SPAN
pada = int(nakshatra_deg // (NAKSHATRA_SPAN / 4)) + 1
# 3. Sublord & SubSubLordKP核心算法)
# 将Vimshottari 120年周期按比例投影到度数上
deg_remainder = deg - 120 * int(deg / 120)
deg_cumulative = 0.0
for i in range(9):
deg_nl = NAKSHATRA_SPAN # 13.333... degrees per nakshatra
for j in range(i, i + 9):
j_mod = j % 9
deg_sl = deg_nl * VIMSHOTTARI_DURATION[j_mod] / 120.0
for k in range(j_mod, j_mod + 9):
k_mod = k % 9
deg_ss = deg_sl * VIMSHOTTARI_DURATION[k_mod] / 120.0
deg_cumulative += deg_ss
if deg_cumulative >= deg_remainder:
return {
'rasi_lord': SIGN_LORDS.get(SIGNS[sign_index], ''),
'sign': SIGNS[sign_index],
'nakshatra': NAKSHATRAS[nakshatra_index],
'nakshatra_lord': STAR_LORDS[nakshatra_index],
'pada': pada,
'sub_lord': KP_LORDS[j_mod],
'sub_sub_lord': KP_LORDS[k_mod],
}
# Fallback
return {
'rasi_lord': SIGN_LORDS.get(SIGNS[sign_index], ''),
'sign': SIGNS[sign_index],
'nakshatra': NAKSHATRAS[nakshatra_index],
'nakshatra_lord': STAR_LORDS[nakshatra_index],
'pada': pada,
'sub_lord': 'Unknown',
'sub_sub_lord': 'Unknown',
}
def get_planet_significators(planet_positions: Dict, houses: List[Dict]) -> Dict:
"""
Planet Significator ABCD(基于 diliprk/VedicAstro MIT 算法)。
对每颗行星计算KP体系的A/B/C/D四个significator
- A: 星宿主星(Nakshatra Lord)所在的宫位
- B: 行星自身所在的宫位
- C: 星宿主星也是宫主星的那些宫位
- D: 行星自身也是宫主星的那些宫位
Args:
planet_positions: {planet_name: {...包含kp_lords/house...}}
houses: 12宫位列表 [{'house': 1, 'sign': 'Aries', 'rasi_lord': 'Mars'}, ...]
Returns:
Planet significators
"""
# 构建辅助索引
planet_kp_data = {}
for pname, pdata in planet_positions.items():
kp_lords = pdata.get('kp_lords', {})
planet_kp_data[pname] = {
'nakshatra_lord': kp_lords.get('nakshatra_lord', ''),
'house': pdata.get('house', 1),
}
results = {}
for pname, kp_data in planet_kp_data.items():
nl = kp_data['nakshatra_lord']
# A: 星宿主星所在的宫位
A = None
if nl in planet_kp_data:
A = planet_kp_data[nl]['house']
# B: 行星自身所在宫位
B = kp_data['house']
# C: 星宿主星是宫主星的那些宫位
C = [h['house'] for h in houses if h.get('rasi_lord', '') == nl]
# D: 行星自身是宫主星的那些宫位
D = [h['house'] for h in houses if h.get('rasi_lord', '') == pname]
results[pname] = {'A': A, 'B': B, 'C': C, 'D': D}
return results
def get_house_significators(planet_positions: Dict, houses: List[Dict]) -> Dict:
"""
House Significator ABCD(基于 diliprk/VedicAstro MIT 算法)。
对每个宫位计算KP体系的A/B/C/D四个significator
- A: 在该宫位居住者的星宿中的行星
- B: 该宫位中的行星
- C: 在该宫位主星的星宿中的行星
- D: 该宫位的主星
Args:
planet_positions: {planet_name: {...包含kp_lords/house...}}
houses: 12宫位列表
Returns:
House significators
"""
# 构建行星ID到星宿主星的映射
planet_nl = {}
for pname, pdata in planet_positions.items():
kp_lords = pdata.get('kp_lords', {})
planet_nl[pname] = kp_lords.get('nakshatra_lord', '')
results = {}
for h in houses:
house_num = h['house']
# A: 在该宫位居住者的星宿中的行星
occupants = [pname for pname, pdata in planet_positions.items()
if pdata.get('house') == house_num]
A = [pname for pname, nl in planet_nl.items() if nl in occupants]
# B: 该宫位中的行星
B = occupants
# C: 在该宫位主星的星宿中的行星
rasi_lord = h.get('rasi_lord', '')
C = [pname for pname, nl in planet_nl.items() if nl == rasi_lord]
# D: 该宫位的主星
D = rasi_lord
results[house_num] = {'A': A, 'B': B, 'C': C, 'D': D}
return results
def calc_kp_analysis(
planet_positions: Dict,
asc_sign: str = 'Aries',
house_cusps: Optional[List[float]] = None,
) -> Dict:
"""
完整KP分析(基于 diliprk/VedicAstro MIT 算法)。
Args:
planet_positions: 行星位置 {planet: {'sign': str, 'degree': float, 'house': int}}
asc_sign: 上升星座名称(无显式宫头时用于 whole-sign 代理)
house_cusps: 可选的 12 个实际宫头黄经。提供时优先用于 KP 宫头与显著星。
Returns:
完整KP分析结果
"""
asc_sign_idx = SIGNS.index(asc_sign) if asc_sign in SIGNS else 0
# 1. 为每颗行星计算KP lords
kp_planets = {}
for pname, pdata in planet_positions.items():
sign = pdata.get('sign', 'Aries')
deg_in_sign = pdata.get('degree', 0) % 30
if sign in SIGNS:
sign_idx = SIGNS.index(sign)
degree = sign_idx * 30 + deg_in_sign
else:
degree = deg_in_sign
kp_lords = get_kp_lords(degree)
kp_planets[pname] = {
'sign': sign,
'degree': degree,
'house': pdata.get('house', 1),
'kp_lords': kp_lords,
}
explicit_cusps = house_cusps is not None
if explicit_cusps and len(house_cusps) != 12:
raise ValueError('house_cusps must contain exactly 12 longitudes')
# 2. 构建宫位信息(含 KP lords)。无实际宫头时保留历史 whole-sign 中点代理。
houses = []
for house_num in range(1, 13):
if explicit_cusps:
house_center_degree = float(house_cusps[house_num - 1]) % 360.0
sign_idx = int(house_center_degree // 30) % 12
else:
sign_idx = (asc_sign_idx + house_num - 1) % 12
house_center_degree = sign_idx * 30 + 15.0
sign_name = SIGNS[sign_idx]
kp_lords = get_kp_lords(house_center_degree)
houses.append({
'house': house_num,
'sign': sign_name,
'rasi_lord': SIGN_LORDS.get(sign_name, ''),
'kp_lords': kp_lords,
'cusp_longitude': round(house_center_degree, 6),
})
# 3. 计算significators
planet_sig = get_planet_significators(kp_planets, houses)
house_sig = get_house_significators(kp_planets, houses)
return {
'method': 'KP (Krishnamurti Paddhati) 系统',
'version': '1.0',
'source': 'diliprk/VedicAstro MIT License',
'maturity_profile': kp_maturity_profile(),
'house_basis': 'explicit_cusps' if explicit_cusps else 'whole_sign_proxy',
'planets': {pname: {'kp_lords': data['kp_lords'], 'significators': planet_sig.get(pname, {})}
for pname, data in kp_planets.items()},
'houses': {h['house']: {'sign': h['sign'], 'cusp_longitude': h['cusp_longitude'], 'kp_lords': h['kp_lords'], 'significators': house_sig.get(h['house'], {})}
for h in houses},
}
def build_kp_report_pack(
kp_analysis: Dict[str, Any],
timeline: Optional[Dict[str, Any]] = None,
*,
profile: Optional[Dict[str, Any]] = None,
western_support: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""Build a report-ready KP evidence pack without upgrading prediction truth.
This is a thin packaging layer for personal reports. It does not recompute
astrology, does not adjudicate event truth, and keeps the KP maturity profile
visible so downstream PL9+ renderers can show KP layers without presenting
blocked timing evidence as verified prediction.
"""
maturity = profile or kp_analysis.get('maturity_profile') or kp_maturity_profile()
planets = kp_analysis.get('planets') or {}
houses = kp_analysis.get('houses') or {}
periods = (timeline or {}).get('periods') or []
runtime_layers = maturity.get('runtime_evidence_layers') or []
runtime_layer_names = [str(layer.get('layer')) for layer in runtime_layers if isinstance(layer, dict) and layer.get('layer')]
closure_gap_matrix = maturity.get('closure_gap_matrix') or [
{'gap_id': reason, 'truth_upgrade_allowed': False}
for reason in (maturity.get('remaining_hard_reasons') or [])
]
closure_gap_ids = [str(row.get('gap_id')) for row in closure_gap_matrix if isinstance(row, dict) and row.get('gap_id')]
claim_boundary = maturity.get('claim_boundary') or 'KP timing truth remains blocked.'
western_support_surface = build_kp_western_support_surface(
western_support,
maturity_profile=maturity,
)
status = 'verified_prediction_allowed' if maturity.get('truth_matrix_allowed') is True else 'observation_only'
must_not_claim = [
'kp_precise_event_timing_truth_closed',
'kp_real_event_replay_completed',
'kp_holdout_20_positive_80_negative_closed',
'kp_dba_periods_are_verified_predictions',
]
executive = (
'KP layers are report-ready as research evidence, but precise event timing remains blocked.'
if status == 'observation_only'
else 'KP timing evidence is marked verified by the maturity profile.'
)
closed_parity_assets = maturity.get('closed_oracle_parity_assets') or {}
markdown = '\n'.join([
'## KP Evidence Pack',
f"- status: `{status}`",
f"- claim_status: `{maturity.get('claim_status')}`",
f"- truth_matrix_allowed: `{maturity.get('truth_matrix_allowed')}`",
f"- closed_oracle_parity_assets: `{', '.join(closed_parity_assets.keys())}`",
f"- planet_count: `{len(planets)}`",
f"- house_count: `{len(houses)}`",
f"- dba_period_count: `{len(periods)}`",
f"- runtime_evidence_layer_count: `{len(runtime_layers)}`",
f"- runtime_evidence_layers: `{', '.join(runtime_layer_names)}`",
f"- closure_gap_count: `{len(closure_gap_matrix)}`",
f"- closure_gap_ids: `{', '.join(closure_gap_ids)}`",
f"- western_support_status: `{western_support_surface.get('status')}`",
f"- western_support_convergence_status: `{western_support_surface.get('convergence', {}).get('status')}`",
f"- western_support_negative_evidence_status: `{western_support_surface.get('negative_evidence', {}).get('status')}`",
f"- claim_boundary: {claim_boundary}",
])
return {
'schema': 'jyotish.kp_report_pack.v1',
'status': status,
'summary': {
'planet_count': len(planets),
'house_count': len(houses),
'dba_period_count': len(periods),
'runtime_evidence_layer_count': len(runtime_layers),
'closure_gap_count': len(closure_gap_matrix),
'claim_status': maturity.get('claim_status'),
'truth_matrix_allowed': maturity.get('truth_matrix_allowed') is True,
'closed_oracle_parity_asset_count': len(closed_parity_assets),
'western_support': {
'status': western_support_surface.get('status'),
'convergence_status': western_support_surface.get('convergence', {}).get('status'),
'negative_evidence_status': western_support_surface.get('negative_evidence', {}).get('status'),
},
},
'kp_analysis': kp_analysis,
'kp_dba_timeline': timeline or {},
'maturity_profile': maturity,
'report_sections': {
'executive_summary': [executive],
'thematic_narrative': [
{
'domain': 'kp_research_evidence',
'paragraph': (
'KP star, sub-lord, significator, and DBA layers can enrich the personal report as '
'auditable research evidence. The report must keep the current blocked timing boundary visible.'
),
'evidence_label': status,
'must_not_claim': list(must_not_claim),
}
],
'evidence_appendix': [
{
'segment_id': 'kp_maturity_profile',
'status': maturity.get('claim_status'),
'source_artifacts': maturity.get('source_artifacts') or {},
'runtime_evidence_layers': runtime_layers,
'closed_oracle_parity_assets': closed_parity_assets,
'closure_gap_matrix': closure_gap_matrix,
'remaining_blockers': maturity.get('remaining_blockers') or [],
'western_support': western_support_surface,
'claim_boundary': claim_boundary,
}
],
'pdf_sections': [executive, claim_boundary],
},
'exports': {
'markdown': markdown,
'ai_evidence_bundle': {
'schema': 'jyotish.kp_report_pack.ai_evidence.v1',
'contains_private_pl9_text': False,
'maturity_profile': maturity,
'runtime_evidence_layers': runtime_layers,
'closed_oracle_parity_assets': closed_parity_assets,
'closure_gap_matrix': closure_gap_matrix,
'western_support': western_support_surface,
'summary': {
'planet_count': len(planets),
'house_count': len(houses),
'dba_period_count': len(periods),
'runtime_evidence_layer_count': len(runtime_layers),
'closure_gap_count': len(closure_gap_matrix),
},
},
},
'audit': {
'status': status,
'must_not_claim': must_not_claim,
'claim_boundary': claim_boundary,
'normalization_boundary': 'packaging_only_no_astrological_recalculation',
},
}
def _kp_next_lords(start_lord: str) -> List[str]:
idx = KP_LORDS.index(start_lord)
return KP_LORDS[idx:] + KP_LORDS[:idx]
def _kp_years_to_days(years: float) -> float:
return years * 365.2425
def _kp_birth_star_balance(moon_longitude: float) -> Tuple[str, float]:
moon_longitude = moon_longitude % 360.0
nak_idx = int(moon_longitude // NAKSHATRA_SPAN) % 27
star_lord = STAR_LORDS[nak_idx]
elapsed = (moon_longitude % NAKSHATRA_SPAN) / NAKSHATRA_SPAN
return star_lord, max(0.0, min(1.0, 1.0 - elapsed))
def _kp_period_score(lords: List[str], planet_house_significators: Optional[Dict[str, Dict]] = None) -> Dict:
supportive_houses = {2, 5, 7, 11}
blocking_houses = {1, 6, 8, 10, 12}
supportive = 0
blocking = 0
details = {}
for lord in lords:
sig = (planet_house_significators or {}).get(lord, {})
houses = set()
for value in sig.values():
if isinstance(value, int):
houses.add(value)
elif isinstance(value, list):
houses.update(v for v in value if isinstance(v, int))
support_hits = sorted(houses & supportive_houses)
block_hits = sorted(houses & blocking_houses)
supportive += len(support_hits)
blocking += len(block_hits)
details[lord] = {'supportive_houses': support_hits, 'blocking_houses': block_hits}
score = supportive - blocking
if score >= 2:
judgement = 'supportive'
elif score <= -2:
judgement = 'blocking'
else:
judgement = 'mixed'
return {
'marriage_score': score,
'supportive_hits': supportive,
'blocking_hits': blocking,
'judgement': judgement,
'lord_details': details,
}
def calc_kp_dba_timeline(
birth_datetime: datetime,
moon_longitude: float,
target_start: datetime,
target_end: datetime,
planet_house_significators: Optional[Dict[str, Dict]] = None,
) -> Dict:
"""Build Vimshottari MD/AD/PD windows for KP-style marriage timing review."""
birth_star_lord, balance = _kp_birth_star_balance(moon_longitude)
periods = []
md_start = birth_datetime
for md_i, md_lord in enumerate(_kp_next_lords(birth_star_lord) * 3):
md_years = VIMSHOTTARI_YEARS[md_lord] * (balance if md_i == 0 else 1.0)
md_end = md_start + timedelta(days=_kp_years_to_days(md_years))
ad_start = md_start
for ad_lord in _kp_next_lords(md_lord):
ad_years = md_years * VIMSHOTTARI_YEARS[ad_lord] / 120.0
ad_end = ad_start + timedelta(days=_kp_years_to_days(ad_years))
pd_start = ad_start
for pd_lord in _kp_next_lords(ad_lord):
pd_years = ad_years * VIMSHOTTARI_YEARS[pd_lord] / 120.0
pd_end = pd_start + timedelta(days=_kp_years_to_days(pd_years))
if pd_end >= target_start and pd_start <= target_end:
scored = _kp_period_score([md_lord, ad_lord, pd_lord], planet_house_significators)
periods.append({
'md_lord': md_lord,
'ad_lord': ad_lord,
'pd_lord': pd_lord,
'start': pd_start.isoformat(),
'end': pd_end.isoformat(),
**scored,
})
pd_start = pd_end
ad_start = ad_end
md_start = md_end
if md_start > target_end:
break
return {
'method': 'KP DBA timeline (Vimshottari MD/AD/PD)',
'birth_star_lord': birth_star_lord,
'birth_star_balance_fraction': balance,
'target_start': target_start.isoformat(),
'target_end': target_end.isoformat(),
'periods': periods,
}