- root SKILL.md is her upstream file (body byte-identical to the 5fb8379c mirror; description quoted so YAML parses and Mastra can load the skill) - chat binds the whole SKILL.md plus references/product_runtime_contract.md (safety-only) instead of a ~477-character excerpt - every turn carries her full strict-workflow-router (synced to upstream) and the domain's event-judgment file; product lists follow as an extra layer - import her 10 sub-skills, family router, interpretation status contract, technique ontology and the 54 files her method names - tests/test_upstream_skill_parity.py (in the quick gate) fails whenever her skill and ours drift - 18 frontend guards that locked the excerpt design updated with 原值/新值/原因 notes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
586 lines
36 KiB
Python
586 lines
36 KiB
Python
"""Explicit Sudasa versions, native age periods and opt-in fixed-year calendars."""
|
|
from copy import deepcopy
|
|
import math
|
|
from pathlib import Path
|
|
import sys
|
|
|
|
if __package__ in (None, ''):
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
from scripts.narayana_dasha import calc_narayana_mahadasha
|
|
|
|
PROFILE = 'sudasa_pvr_20_2_first_cycle'
|
|
SOURCE = 'https://vedicastrologer.org/articles/vedic_astro_textbook.pdf#page=275'
|
|
PVR_DURATION_PROFILE = 'pvr_18_2_2_node_dignity_v1'
|
|
AD_PROFILE = 'sudasa_sjvc15_full_md_equal_ad'
|
|
AD_SOURCE = 'https://argala.ru/pdf-files/sudasa_calculation.pdf#page=4'
|
|
BIRTH_AD_PROFILE = 'sudasa_sjvc15_elapsed_clip_inference_v1'
|
|
CALENDAR_PROFILE = 'sudasa_native_explicit_fixed_year_calendar_v1'
|
|
SJVC15_PROFILE = 'sudasa_sjvc15_2001_first_cycle_v1'
|
|
SOLAR_CALENDAR_PROFILE = 'sudasa_epoch_sidereal_solar_year_candidate_v1'
|
|
ANCHORED_AD_PROFILE = 'sudasa_sjvc15_confirmed_md_solar_ad_candidate_v1'
|
|
|
|
|
|
def _sidereal_solar_clock(epoch, ayanamsa, solar_position_profile):
|
|
"""Shared astronomical clock; never selects a Sudasa seed or period order."""
|
|
from datetime import datetime, timedelta, timezone
|
|
import swisseph as swe
|
|
from scripts.ayanamsa_utils import apply_ayanamsa, normalize_ayanamsa_name
|
|
from scripts.transit_search import _angular_roots
|
|
if solar_position_profile not in ('apparent', 'true_position'):
|
|
raise ValueError('explicit solar_position_profile apparent or true_position required')
|
|
if not isinstance(epoch, str):
|
|
raise ValueError('epoch must be a timezone-aware ISO timestamp')
|
|
start = datetime.fromisoformat(epoch)
|
|
if start.tzinfo is None or start.utcoffset() is None:
|
|
raise ValueError('epoch must include explicit UTC offset')
|
|
start = start.astimezone(timezone.utc)
|
|
if not isinstance(ayanamsa, str) or not ayanamsa.strip():
|
|
raise ValueError('explicit ayanamsa required')
|
|
name = normalize_ayanamsa_name(ayanamsa)
|
|
flags = swe.FLG_MOSEPH | swe.FLG_SIDEREAL
|
|
if solar_position_profile == 'true_position':
|
|
flags |= swe.FLG_TRUEPOS
|
|
def longitude(stamp):
|
|
apply_ayanamsa(name, swe)
|
|
return swe.calc_ut(stamp.timestamp()/86400+2440587.5, swe.SUN, flags)[0][0] % 360
|
|
initial = longitude(start)
|
|
cache = {0.0: (start, 0.0)}
|
|
clock = dict(ayanamsa=name, initial_sun_longitude=initial, flags=flags,
|
|
ephemeris='Moshier', year_estimate_for_bracketing_only=365.25636,
|
|
boundary_count=1, maximum_root_error_degrees=0.0)
|
|
def boundary(age):
|
|
if not math.isfinite(age) or not 0 <= age <= 200:
|
|
raise ValueError('solar clock supports native offsets from 0 to 200 years')
|
|
if age not in cache:
|
|
guess = start + timedelta(days=age*365.25636-10)
|
|
roots = _angular_roots(longitude, (initial+360*age)%360, guess, 20)
|
|
if len(roots) != 1:
|
|
raise ValueError('solar-year boundary unavailable or ambiguous')
|
|
cache[age] = roots[0]
|
|
clock['boundary_count'] = len(cache)
|
|
clock['maximum_root_error_degrees'] = max(v[1] for v in cache.values())
|
|
return cache[age]
|
|
return boundary, clock
|
|
|
|
|
|
def calculate_sudasa_anchored_antardasa(*, md_start, md_start_role, anchor_source,
|
|
md_sign_idx, full_md_years, saturn_sign_idx, ketu_sign_idx, profile,
|
|
ayanamsa, solar_position_profile):
|
|
"""Date source-defined full-parent children from a confirmed MD start.
|
|
|
|
This does not compute the MD start from birth, fit a source table, or restart
|
|
children at the remaining portion of a birth-clipped parent.
|
|
"""
|
|
if md_start_role != 'confirmed_full_md_start':
|
|
raise ValueError('confirmed_full_md_start required; birth remainder is not a full MD')
|
|
if not isinstance(anchor_source, str) or not anchor_source.strip():
|
|
raise ValueError('explicit anchor_source required')
|
|
native = build_sudasa_antardasa_report(md_sign_idx=md_sign_idx,
|
|
full_md_years=full_md_years, saturn_sign_idx=saturn_sign_idx,
|
|
ketu_sign_idx=ketu_sign_idx, profile=profile)['evidence_appendix'][0]
|
|
boundary, clock = _sidereal_solar_clock(md_start, ayanamsa, solar_position_profile)
|
|
parent_key = f'sudasa:confirmed-md:{md_sign_idx}'
|
|
rows = []
|
|
for index, row in enumerate(native['periods']):
|
|
left, aerr = boundary(row['start_offset_years'])
|
|
right, berr = boundary(row['end_offset_years'])
|
|
if right <= left:
|
|
raise ValueError('solar AD boundaries out of order')
|
|
rows.append(dict(row, period_key=f'{parent_key}:ad:{index+1}', parent_key=parent_key,
|
|
start_datetime_utc=left.isoformat(), end_datetime_utc=right.isoformat(),
|
|
duration_days=(right-left).total_seconds()/86400,
|
|
start_solar_arc_degrees=row['start_offset_years']*360,
|
|
end_solar_arc_degrees=row['end_offset_years']*360,
|
|
solar_root_error_degrees=max(aerr, berr)))
|
|
return dict(status='parameter_sensitive', execution_status='executed',
|
|
variant_profile_id=ANCHORED_AD_PROFILE, tradition_profile=native.get('tradition_profile', 'pvr_sjvc_lesson15_2001'),
|
|
child_profile=profile, source=AD_SOURCE, solar_position_profile=solar_position_profile,
|
|
calendar_basis='confirmed_full_md_start_sidereal_solar_360_degrees_per_nominal_year',
|
|
anchor=dict(role=md_start_role, source=anchor_source.strip(),
|
|
datetime_utc=rows[0]['start_datetime_utc'], inferred_from_birth=False),
|
|
parent=dict(period_key=parent_key, sign_idx=md_sign_idx, full_years=full_md_years,
|
|
start_datetime_utc=rows[0]['start_datetime_utc'],
|
|
end_datetime_utc=rows[-1]['end_datetime_utc']),
|
|
periods=rows, native_offset_evidence=native, solar_clock=clock,
|
|
limits=['Caller confirms full MD start and full length; no birth-balance or MD-start calculation',
|
|
'Equal nominal years mean equal solar arcs, not equal calendar days',
|
|
'Solar clock inferred from SJVC15 dated AD table; not an explicit universal textual year rule',
|
|
'One dated parent comparison does not independently validate all cases or the birth anchor',
|
|
'No PD, second-cycle inference or automatic customer export'])
|
|
|
|
|
|
def build_sudasa_anchored_antardasa_report(**kwargs):
|
|
from types import SimpleNamespace
|
|
import json
|
|
from scripts.jyotish_engine import cmd_dasha
|
|
result = cmd_dasha(SimpleNamespace(sudasa_anchored_ad_request=kwargs))
|
|
lines = ['# Sudasa — confirmed full-MD solar antardasas', '',
|
|
f'Anchor: {result["anchor"]["datetime_utc"]}; source: {result["anchor"]["source"]}',
|
|
'', *result['limits'], '', '| Sign index | Start UTC | End UTC | Days |',
|
|
'| --- | --- | --- | --- |']
|
|
lines += ['| ' + ' | '.join(str(r[k]) for k in
|
|
('sign_idx', 'start_datetime_utc', 'end_datetime_utc', 'duration_days')) + ' |'
|
|
for r in result['periods']]
|
|
lines += ['', '## Evidence', '```json', json.dumps(result, ensure_ascii=False, indent=2), '```']
|
|
return dict(status=result['status'], customer_export_admitted=False, evidence_appendix=[result],
|
|
exports=dict(markdown='\n'.join(lines), ai_evidence_bundle=deepcopy(result)))
|
|
|
|
|
|
def calculate_sudasa_solar_calendar(*, epoch, ascendant_longitude, planet_longitudes,
|
|
cycle_profile, duration_profile, dual_lord_profile, ayanamsa, node_mode,
|
|
position_mode, solar_position_profile, child_profile=None):
|
|
"""Map native age offsets to sidereal solar motion from explicit epoch.
|
|
|
|
A named candidate clock; the SJVC15 printed first anchor is not reproduced.
|
|
Existing native MD/AD rules provide every offset; only astronomy is inverted.
|
|
"""
|
|
if solar_position_profile not in ('apparent', 'true_position'):
|
|
raise ValueError('explicit solar_position_profile apparent or true_position required')
|
|
# Obtain already-implemented age offsets and child decisions; the temporary
|
|
# fixed-day dates are entirely replaced, not used as astronomical targets.
|
|
result = calculate_sudasa_calendar(epoch=epoch, year_days=360,
|
|
ascendant_longitude=ascendant_longitude, planet_longitudes=planet_longitudes,
|
|
cycle_profile=cycle_profile, duration_profile=duration_profile,
|
|
dual_lord_profile=dual_lord_profile, ayanamsa=ayanamsa, node_mode=node_mode,
|
|
position_mode=position_mode, child_profile=child_profile)
|
|
boundary, clock = _sidereal_solar_clock(epoch, ayanamsa, solar_position_profile)
|
|
def convert(row):
|
|
left, aerr = boundary(row['start_age_years'])
|
|
right, berr = boundary(row['end_age_years'])
|
|
if right < left:
|
|
raise ValueError('solar-year boundaries out of order')
|
|
row.update(start_datetime_utc=left.isoformat(), end_datetime_utc=right.isoformat(),
|
|
duration_days=(right-left).total_seconds()/86400,
|
|
start_solar_arc_degrees=row['start_age_years']*360,
|
|
end_solar_arc_degrees=row['end_age_years']*360,
|
|
solar_root_error_degrees=max(aerr,berr))
|
|
for child in row.get('antardashas', []):
|
|
convert(child)
|
|
for row in result['periods']:
|
|
convert(row)
|
|
result.pop('year_days')
|
|
result.update(variant_profile_id=SOLAR_CALENDAR_PROFILE,
|
|
calendar_basis='epoch_anchored_sidereal_solar_360_degrees_per_nominal_year',
|
|
solar_position_profile=solar_position_profile,
|
|
solar_clock=clock)
|
|
result['limits'][0] = 'Epoch-anchored sidereal solar-year candidate, not a universal Sudasa year or reconstructed historical software setting'
|
|
result['limits'].append('SJVC15 printed integer-year boundaries support sidereal solar motion, but published first balance/anchor differs by about 15.8 hours under supplied printed positions; not date verified')
|
|
return result
|
|
|
|
|
|
def build_sudasa_solar_calendar_report(**kwargs):
|
|
from types import SimpleNamespace
|
|
from scripts.jyotish_engine import cmd_dasha
|
|
result = cmd_dasha(SimpleNamespace(sudasa_solar_request=kwargs))
|
|
return _calendar_report(result, 'Sudasa — epoch-anchored sidereal solar-year candidate')
|
|
|
|
|
|
def calculate_sudasa_sjvc15_first_cycle(*, sree_longitude, planet_longitudes,
|
|
duration_profile, dual_lord_profile, profile):
|
|
"""Lesson 15 sections 3.1/3.2, distinct from the older parity-only cycle."""
|
|
if profile != SJVC15_PROFILE:
|
|
raise ValueError('explicit SJVC15 first-cycle profile required')
|
|
if duration_profile not in ('jaimini_odd_footed_dignity_v2', PVR_DURATION_PROFILE):
|
|
raise ValueError('SJVC15 requires an explicit dignity-aware duration profile')
|
|
result = calculate_sudasa_first_cycle(sree_longitude=sree_longitude,
|
|
planet_longitudes=planet_longitudes, duration_profile=duration_profile,
|
|
dual_lord_profile=dual_lord_profile, profile=PROFILE)
|
|
# Section 3.3 explicitly reuses the section 3.1 MD ordering rule.
|
|
order = build_sudasa_antardasa_report(md_sign_idx=int(sree_longitude//30),
|
|
full_md_years=12, saturn_sign_idx=int(planet_longitudes['Saturn']//30),
|
|
ketu_sign_idx=int(planet_longitudes['Ketu']//30), profile=AD_PROFILE)['evidence_appendix'][0]
|
|
durations = {r['sign_idx']: dict(r, years=r['full_years']) for r in result['periods']}
|
|
rows, fraction = _first_cycle(sree_longitude, durations, direction=order['direction'])
|
|
result.update(variant_profile_id=SJVC15_PROFILE, source=AD_SOURCE,
|
|
tradition_profile='pvr_sjvc_lesson15_2001', periods=rows,
|
|
first_balance_fraction=fraction, direction=order['direction'],
|
|
direction_exception=order['direction_exception'],
|
|
scope='SJVC15 first-cycle MD age-years with Saturn/Ketu seed exceptions',
|
|
limits=['First cycle only; no 1999 second-cycle complement assumed',
|
|
'Full-MD AD available separately; birth clipping requires explicit inference profile',
|
|
'Supplied Sree Lagna is not recomputed',
|
|
'Concurrent Saturn/Ketu exception precedence unresolved',
|
|
'No independent calendar-date or event verification'])
|
|
return result
|
|
|
|
|
|
def calculate_sudasa_calendar(*, epoch, year_days, ascendant_longitude,
|
|
planet_longitudes, cycle_profile, duration_profile, dual_lord_profile,
|
|
ayanamsa, node_mode, position_mode, child_profile=None):
|
|
"""Date the existing native MD age offsets using a caller-selected fixed year.
|
|
|
|
No source-specific calendar default is inferred. The two historical cycle
|
|
versions remain separate; no newer AD rule is silently attached to either.
|
|
"""
|
|
from datetime import datetime, timedelta, timezone
|
|
from scripts.ayanamsa_utils import normalize_ayanamsa_name
|
|
from scripts.special_lagnas import calculate_sree_lagna_pvr
|
|
if cycle_profile not in (PROFILE, TWO_CYCLE_PROFILE, SJVC15_PROFILE):
|
|
raise ValueError('explicit supported Sudasa cycle_profile required')
|
|
if child_profile not in (None, AD_PROFILE, BIRTH_AD_PROFILE):
|
|
raise ValueError('explicit supported Sudasa child_profile required')
|
|
if child_profile is not None and cycle_profile != SJVC15_PROFILE:
|
|
raise ValueError('SJVC15 children require the same SJVC15 MD cycle profile')
|
|
if (isinstance(year_days, bool) or not isinstance(year_days, (int, float))
|
|
or not math.isfinite(year_days) or not 300 <= year_days <= 400):
|
|
raise ValueError('explicit fixed year_days must be finite in 300..400')
|
|
if not isinstance(epoch, str):
|
|
raise ValueError('epoch must be a timezone-aware ISO timestamp')
|
|
start = datetime.fromisoformat(epoch)
|
|
if start.tzinfo is None or start.utcoffset() is None:
|
|
raise ValueError('epoch must include explicit UTC offset')
|
|
start = start.astimezone(timezone.utc)
|
|
if not isinstance(ayanamsa, str) or not ayanamsa.strip():
|
|
raise ValueError('explicit ayanamsa provenance required')
|
|
if node_mode not in ('mean', 'true') or position_mode not in ('legacy', 'mean', 'apparent'):
|
|
raise ValueError('explicit valid supplied-position provenance required')
|
|
if not isinstance(planet_longitudes, dict) or 'Moon' not in planet_longitudes:
|
|
raise ValueError('Moon longitude required')
|
|
seed = calculate_sree_lagna_pvr(ascendant_longitude=ascendant_longitude,
|
|
moon_longitude=planet_longitudes['Moon'])
|
|
request = dict(sree_longitude=seed['longitude'], planet_longitudes=planet_longitudes,
|
|
duration_profile=duration_profile, dual_lord_profile=dual_lord_profile,
|
|
profile=cycle_profile)
|
|
if cycle_profile == PROFILE:
|
|
native = calculate_sudasa_first_cycle(**request)
|
|
elif cycle_profile == SJVC15_PROFILE:
|
|
native = calculate_sudasa_sjvc15_first_cycle(**request)
|
|
else:
|
|
native = build_sudasa_two_cycle_report(**request)['evidence_appendix'][0]
|
|
periods = []
|
|
for index, row in enumerate(native['periods']):
|
|
left = start + timedelta(days=row['start_age_years'] * year_days)
|
|
right = start + timedelta(days=row['end_age_years'] * year_days)
|
|
cycle = row.get('cycle', 1)
|
|
periods.append(dict(row, cycle=cycle, period_key=f'sudasa:cycle:{cycle}:md:{index % 12 + 1}',
|
|
start_datetime_utc=left.isoformat(), end_datetime_utc=right.isoformat(),
|
|
duration_days=(right-left).total_seconds()/86400,
|
|
zero_duration=left == right))
|
|
if child_profile is not None:
|
|
parent = periods[-1]
|
|
parent['antardashas'] = []
|
|
saturn, ketu = (int(planet_longitudes[p]//30) for p in ('Saturn','Ketu'))
|
|
fraction = native['first_balance_fraction'] if index == 0 else 1.0
|
|
if parent['zero_duration']:
|
|
parent['antardasha_status'] = 'not_applicable'
|
|
parent['antardasha_reason'] = 'zero_duration_parent'
|
|
continue
|
|
if saturn == ketu == row['sign_idx']:
|
|
parent['antardasha_status'] = 'blocked'
|
|
parent['antardasha_reason'] = 'concurrent_saturn_ketu_precedence_unresolved'
|
|
continue
|
|
if fraction < 1 and child_profile != BIRTH_AD_PROFILE:
|
|
parent['antardasha_status'] = 'blocked'
|
|
parent['antardasha_reason'] = 'birth_clipping_requires_explicit_inference_profile'
|
|
continue
|
|
args = dict(md_sign_idx=row['sign_idx'], full_md_years=row['full_years'],
|
|
saturn_sign_idx=saturn, ketu_sign_idx=ketu)
|
|
if fraction < 1:
|
|
children = build_sudasa_birth_antardasa_report(**args,
|
|
remaining_fraction=fraction, profile=BIRTH_AD_PROFILE)['evidence_appendix'][0]
|
|
child_rows = children['periods']
|
|
else:
|
|
children = build_sudasa_antardasa_report(**args, profile=AD_PROFILE)['evidence_appendix'][0]
|
|
child_rows = [dict(r, full_child_index=i, start_age_years=r['start_offset_years'],
|
|
end_age_years=r['end_offset_years'], clipped_at_birth=False)
|
|
for i,r in enumerate(children['periods'])]
|
|
parent['antardasha_status'] = 'parameter_sensitive' if fraction < 1 else 'executed'
|
|
parent['antardasha_profile'] = BIRTH_AD_PROFILE if fraction < 1 else AD_PROFILE
|
|
for i, child in enumerate(child_rows):
|
|
a = left if i == 0 else start + timedelta(days=(row['start_age_years']+child['start_age_years'])*year_days)
|
|
b = right if i == len(child_rows)-1 else start + timedelta(days=(row['start_age_years']+child['end_age_years'])*year_days)
|
|
parent['antardashas'].append(dict(child,
|
|
period_key=f'{parent["period_key"]}:ad:{child["full_child_index"]+1}',
|
|
parent_key=parent['period_key'],
|
|
start_age_years=row['start_age_years']+child['start_age_years'],
|
|
end_age_years=row['start_age_years']+child['end_age_years'],
|
|
start_datetime_utc=a.isoformat(), end_datetime_utc=b.isoformat(),
|
|
duration_days=(b-a).total_seconds()/86400))
|
|
return dict(status='parameter_sensitive', variant_profile_id=CALENDAR_PROFILE,
|
|
cycle_profile=cycle_profile, child_profile=child_profile, calendar_basis='caller_selected_fixed_days_per_year',
|
|
year_days=year_days, sree_lagna=seed, periods=periods,
|
|
position_provenance=dict(ayanamsa=normalize_ayanamsa_name(ayanamsa),
|
|
node_mode=node_mode, position_mode=position_mode,
|
|
boundary='Caller supplies consistent sidereal positions at epoch; this entry does not recompute or verify their ephemeris.'),
|
|
native_age_year_result=native,
|
|
sources=[native['source'], 'https://www.vedicastrologer.org/jh/features.htm'],
|
|
limits=['Fixed-year conversion is an explicit caller convention, not a source-prescribed Sudasa year',
|
|
'AD only on SJVC15 MD profile; birth-child clipping is separately opt-in inference; no PD or historical-cycle mixing',
|
|
'First interval starts at supplied epoch and includes native birth balance; no pre-birth full start asserted',
|
|
'Zero-duration rows remain visible but do not define an active time interval',
|
|
'No independent calendar-date or event validation; customer export not admitted'])
|
|
|
|
|
|
def build_sudasa_calendar_report(**kwargs):
|
|
from types import SimpleNamespace
|
|
import json
|
|
from scripts.jyotish_engine import cmd_dasha
|
|
result = cmd_dasha(SimpleNamespace(sudasa_calendar_request=kwargs))
|
|
return _calendar_report(result, 'Sudasa — explicit fixed-year calendar')
|
|
|
|
|
|
def _calendar_report(result, title):
|
|
import json
|
|
lines = [f'# {title}', '',
|
|
f'Cycle profile: {result["cycle_profile"]}; clock: {result["calendar_basis"]}; fixed year_days: {result.get("year_days", "not applicable")}.', '',
|
|
*result['limits'], '', '| Cycle | Sign | Start UTC | End UTC | Days |',
|
|
'| --- | --- | --- | --- | --- |']
|
|
lines += ['| ' + ' | '.join(str(r[k]) for k in ('cycle','sign',
|
|
'start_datetime_utc','end_datetime_utc','duration_days')) + ' |' for r in result['periods']]
|
|
lines += ['', '## Evidence', '```json', json.dumps(result, ensure_ascii=False, indent=2), '```']
|
|
return dict(status=result['status'], customer_export_admitted=False, evidence_appendix=[result],
|
|
exports=dict(markdown='\n'.join(lines), ai_evidence_bundle=deepcopy(result)))
|
|
|
|
|
|
def build_sudasa_birth_antardasa_report(*, md_sign_idx, full_md_years,
|
|
saturn_sign_idx, ketu_sign_idx, remaining_fraction, profile):
|
|
"""Explicit inference: intersect full-MD equal children with post-birth time.
|
|
|
|
Not a source-verified birth-child algorithm. Never restart twelve children
|
|
over the remaining MD, and never infer a calendar-year convention.
|
|
"""
|
|
if profile != BIRTH_AD_PROFILE:
|
|
raise ValueError('explicit elapsed-clip inference profile required')
|
|
if isinstance(remaining_fraction, bool) or not isinstance(remaining_fraction, (int, float)) or not math.isfinite(remaining_fraction) or not 0 < remaining_fraction <= 1:
|
|
raise ValueError('remaining_fraction must be finite in (0,1]')
|
|
full = build_sudasa_antardasa_report(md_sign_idx=md_sign_idx,
|
|
full_md_years=full_md_years, saturn_sign_idx=saturn_sign_idx,
|
|
ketu_sign_idx=ketu_sign_idx, profile=AD_PROFILE)['exports']['ai_evidence_bundle']
|
|
elapsed = full_md_years * (1 - remaining_fraction)
|
|
periods = []
|
|
for index, row in enumerate(full['periods']):
|
|
# Half-open intervals: a child ending at birth is not birth-active.
|
|
if row['end_offset_years'] <= elapsed:
|
|
continue
|
|
start = max(row['start_offset_years'], elapsed)
|
|
periods.append(dict(sign_idx=row['sign_idx'], full_child_index=index,
|
|
full_years=row['years'], years=row['end_offset_years'] - start,
|
|
start_age_years=start - elapsed,
|
|
end_age_years=row['end_offset_years'] - elapsed,
|
|
clipped_at_birth=row['start_offset_years'] < elapsed))
|
|
result = dict(status='parameter_sensitive', variant_profile_id=profile,
|
|
source=AD_SOURCE, rule_basis='inference from full-MD equal children, not a published birth-clipping example',
|
|
full_md_years=full_md_years, remaining_fraction=remaining_fraction,
|
|
elapsed_md_years=elapsed, full_md_observation=full, periods=periods,
|
|
limits=['Birth clipping is an explicit inference; traditional worked example still required',
|
|
'Age-year offsets only; no calendar dates or automatic customer routing'])
|
|
lines = ['# Sudasa — inferred birth-child clipping', '', f'Profile: {profile}',
|
|
f'Status: {result["status"]}', result['rule_basis'], '',
|
|
'| Sign index | Original child index | Remaining years | Start age | End age |',
|
|
'| --- | --- | --- | --- | --- |']
|
|
lines += ['| ' + ' | '.join(str(row[k]) for k in ('sign_idx', 'full_child_index',
|
|
'years', 'start_age_years', 'end_age_years')) + ' |' for row in periods]
|
|
lines += ['', *result['limits']]
|
|
return dict(status=result['status'], customer_export_admitted=False,
|
|
evidence_appendix=[result], exports=dict(markdown='\n'.join(lines),
|
|
ai_evidence_bundle=deepcopy(result)))
|
|
TWO_CYCLE_PROFILE = 'sudasa_pvr_1999_remaining_complement_md'
|
|
TWO_CYCLE_SOURCE = 'https://www.indiadivine.org/content/topic/1186038-sudasa-calculation-and-exercise-sudasasbusinessman/'
|
|
|
|
|
|
def build_sudasa_two_cycle_report(*, sree_longitude, planet_longitudes,
|
|
duration_profile, dual_lord_profile, profile):
|
|
"""1999 forwarded author text: second cycle complements actual first cycle.
|
|
|
|
This explicitly includes the birth remainder, not the untrimmed first MD.
|
|
Do not attach the 2001 AD profile without a separate version-selection rule.
|
|
"""
|
|
if profile != TWO_CYCLE_PROFILE:
|
|
raise ValueError('explicit PVR 1999 remaining-complement profile required')
|
|
result = calculate_sudasa_first_cycle(sree_longitude=sree_longitude,
|
|
planet_longitudes=planet_longitudes, duration_profile=duration_profile,
|
|
dual_lord_profile=dual_lord_profile, profile=PROFILE)
|
|
first = result['periods']
|
|
for row in first:
|
|
row['cycle'] = 1
|
|
elapsed = first[-1]['end_age_years']
|
|
second = []
|
|
for row in first:
|
|
years = 12 - row['years']
|
|
second.append(dict(sign_idx=row['sign_idx'], sign=row['sign'], lord=row['lord'],
|
|
cycle=2, years=years, complement_basis_years=row['years'],
|
|
start_age_years=elapsed, end_age_years=elapsed + years))
|
|
elapsed += years
|
|
result.update(periods=first + second, variant_profile_id=TWO_CYCLE_PROFILE,
|
|
tradition_profile='pvr_1999_forwarded_author_text', source=TWO_CYCLE_SOURCE,
|
|
scope='Two MD cycles in age-years; 1999 parity order and remaining-duration complement',
|
|
limits=['Forwarded author text, not an independently authenticated original archive',
|
|
'No AD/PD, birth child clipping or calendar dates',
|
|
'Do not silently combine with 2001 Saturn/Ketu sequence exceptions or AD rules',
|
|
'Zero-year second-cycle periods retained explicitly',
|
|
'No independent event verification'])
|
|
lines = ['# Sudasa — PVR 1999 two MD cycles', '', f'Source: {TWO_CYCLE_SOURCE}',
|
|
f'Profile: {profile}', f'Duration: {duration_profile}; dual lord: {dual_lord_profile}',
|
|
'', '| Cycle | Sign | Years | Start age | End age |', '| --- | --- | --- | --- | --- |']
|
|
lines += ['| ' + ' | '.join(str(row[k]) for k in
|
|
('cycle', 'sign', 'years', 'start_age_years', 'end_age_years')) + ' |'
|
|
for row in result['periods']]
|
|
lines += ['', *result['limits']]
|
|
return dict(status=result['status'], customer_export_admitted=False,
|
|
evidence_appendix=[result], exports=dict(markdown='\n'.join(lines),
|
|
ai_evidence_bundle=deepcopy(result)))
|
|
|
|
|
|
def build_sudasa_antardasa_report(*, md_sign_idx, full_md_years,
|
|
saturn_sign_idx, ketu_sign_idx, profile):
|
|
"""SJVC lesson 15 section 3.3: children of an untrimmed full MD.
|
|
|
|
Offsets are relative to the full MD start, not birth or a calendar epoch.
|
|
"""
|
|
if profile != AD_PROFILE:
|
|
raise ValueError('explicit SJVC15 full-MD antardasa profile required')
|
|
for sign in (md_sign_idx, saturn_sign_idx, ketu_sign_idx):
|
|
if type(sign) is not int or not 0 <= sign < 12:
|
|
raise ValueError('sign indices must be integers in [0,12)')
|
|
if isinstance(full_md_years, bool) or not isinstance(full_md_years, (int, float)) or not math.isfinite(full_md_years) or not 0 < full_md_years <= 12:
|
|
raise ValueError('full untrimmed MD years must be finite in (0,12]')
|
|
if saturn_sign_idx == ketu_sign_idx == md_sign_idx:
|
|
raise ValueError('Saturn/Ketu concurrent exception precedence unresolved in lesson 15')
|
|
direction = 1 if md_sign_idx % 2 == 0 else -1
|
|
exception = 'none'
|
|
if saturn_sign_idx == md_sign_idx:
|
|
direction, exception = 1, 'saturn_forward'
|
|
elif ketu_sign_idx == md_sign_idx:
|
|
direction, exception = -direction, 'ketu_reverse'
|
|
offsets = [g + q * 3 for g in range(3) for q in range(4)]
|
|
rows = [dict(sign_idx=(md_sign_idx + direction * offset) % 12,
|
|
years=full_md_years / 12,
|
|
start_offset_years=full_md_years * i / 12,
|
|
end_offset_years=full_md_years * (i + 1) / 12)
|
|
for i, offset in enumerate(offsets)]
|
|
result = dict(status='source_bounded_wired', variant_profile_id=AD_PROFILE,
|
|
source=AD_SOURCE, md_sign_idx=md_sign_idx, full_md_years=full_md_years,
|
|
direction=direction, direction_exception=exception, periods=rows,
|
|
limits=['Full untrimmed MD only; do not pass birth remainder as full duration',
|
|
'No birth clipping, calendar dates, second cycle or PD',
|
|
'Concurrent Saturn/Ketu exception requires another explicit source'])
|
|
lines = ['# Sudasa — SJVC15 full-MD antardasas', '', f'Source: {AD_SOURCE}',
|
|
f'Profile: {AD_PROFILE}', '', '| Sign index (Aries=0) | Years | Start offset | End offset |',
|
|
'| --- | --- | --- | --- |']
|
|
lines += ['| ' + ' | '.join(str(row[k]) for k in
|
|
('sign_idx', 'years', 'start_offset_years', 'end_offset_years')) + ' |' for row in rows]
|
|
lines += ['', *result['limits']]
|
|
return dict(status=result['status'], customer_export_admitted=False,
|
|
evidence_appendix=[result], exports=dict(markdown='\n'.join(lines),
|
|
ai_evidence_bundle=deepcopy(result)))
|
|
|
|
|
|
def build_sudasa_from_positions_report(*, ascendant_longitude, planet_longitudes,
|
|
duration_profile, dual_lord_profile, profile):
|
|
"""Native SL -> first-cycle MD using already computed chart positions."""
|
|
from scripts.special_lagnas import calculate_sree_lagna_pvr
|
|
if not isinstance(planet_longitudes, dict) or 'Moon' not in planet_longitudes:
|
|
raise ValueError('Moon longitude required for native Sree Lagna')
|
|
seed = calculate_sree_lagna_pvr(ascendant_longitude=ascendant_longitude,
|
|
moon_longitude=planet_longitudes['Moon'])
|
|
report = build_sudasa_first_cycle_report(sree_longitude=seed['longitude'],
|
|
planet_longitudes=planet_longitudes, duration_profile=duration_profile,
|
|
dual_lord_profile=dual_lord_profile, profile=profile)
|
|
for evidence in (report['evidence_appendix'][0], report['exports']['ai_evidence_bundle']):
|
|
evidence['sree_lagna'] = deepcopy(seed)
|
|
evidence['limits'].remove('Supplied Sree Lagna is not recomputed')
|
|
evidence['scope'] = 'Native Sree Lagna and first-cycle MD age-years from supplied consistent sidereal positions'
|
|
report['exports']['markdown'] = report['exports']['markdown'].replace(
|
|
'Supplied Sree Lagna is not recomputed', 'Sree Lagna computed natively using PVR section 5.7').replace(
|
|
'first-cycle MD age-years only; caller supplies calculated Sree Lagna and consistent sidereal positions',
|
|
'Native Sree Lagna and first-cycle MD age-years from supplied consistent sidereal positions')
|
|
report['exports']['markdown'] += f'\n\nSree Lagna: {seed["longitude"]} degrees; {seed["profile"]}'
|
|
return report
|
|
|
|
|
|
def _first_cycle(sree_longitude, durations, direction=None):
|
|
sign = int(sree_longitude // 30)
|
|
direction = (1 if sign % 2 == 0 else -1) if direction is None else direction
|
|
offsets = [group + quadrant * 3 for group in range(3) for quadrant in range(4)]
|
|
fraction = (30 - sree_longitude % 30) / 30
|
|
rows, elapsed = [], 0.0
|
|
for index, offset in enumerate(offsets):
|
|
current = (sign + direction * offset) % 12
|
|
full = durations[current]['years']
|
|
years = full * fraction if index == 0 else full
|
|
rows.append(dict(sign_idx=current, sign=durations[current]['sign'],
|
|
full_years=full, years=years, start_age_years=elapsed,
|
|
end_age_years=elapsed + years, lord=durations[current]['lord']))
|
|
elapsed += years
|
|
return rows, fraction
|
|
|
|
|
|
def calculate_sudasa_first_cycle(*, sree_longitude, planet_longitudes,
|
|
duration_profile, dual_lord_profile, profile):
|
|
if profile != PROFILE:
|
|
raise ValueError('explicit first-cycle Sudasa profile required')
|
|
if isinstance(sree_longitude, bool) or not isinstance(sree_longitude, (int, float)) or not math.isfinite(sree_longitude) or not 0 <= sree_longitude < 360:
|
|
raise ValueError('Sree Lagna longitude must be finite in [0,360)')
|
|
required = {'Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Rahu', 'Ketu'}
|
|
if not isinstance(planet_longitudes, dict) or set(planet_longitudes) != required:
|
|
raise ValueError('all nine planetary longitudes required; no lord-position fallback')
|
|
for longitude in planet_longitudes.values():
|
|
if isinstance(longitude, bool) or not isinstance(longitude, (int, float)) or not math.isfinite(longitude) or not 0 <= longitude < 360:
|
|
raise ValueError('planetary longitudes must be finite in [0,360)')
|
|
if duration_profile not in {'jaimini_odd_footed_v1', 'jaimini_odd_footed_dignity_v2', PVR_DURATION_PROFILE}:
|
|
raise ValueError('explicit Jaimini duration profile required')
|
|
# Reuse native duration/lord selection; Narayana sequence and age offsets are discarded.
|
|
durations = calc_narayana_mahadasha(0, planet_longitudes,
|
|
duration_profile=('jaimini_odd_footed_dignity_v2' if duration_profile == PVR_DURATION_PROFILE
|
|
else duration_profile), dual_lord_profile=dual_lord_profile, cycle_count=1)
|
|
# PVR Table 6 and section 18.2.2: nodes have explicit exaltation/debilitation.
|
|
# Keep this scoped to the opt-in Sudasa duration parameter, not shared defaults.
|
|
if duration_profile == PVR_DURATION_PROFILE:
|
|
node_dignities = {'Rahu': (2, 8), 'Ketu': (8, 2)}
|
|
for row in durations:
|
|
if row['lord'] in node_dignities:
|
|
exalted, debilitated = node_dignities[row['lord']]
|
|
placement = int(planet_longitudes[row['lord']] // 30)
|
|
adjustment = int(placement == exalted) - int(placement == debilitated)
|
|
row['years'] = max(0, min(12, row['years'] + adjustment))
|
|
rows, fraction = _first_cycle(sree_longitude, {row['sign_idx']: row for row in durations})
|
|
return dict(status='source_bounded_wired', variant_profile_id=PROFILE,
|
|
tradition_profile='pvr_integrated_approach_20_2', periods=rows,
|
|
first_balance_fraction=fraction, duration_profile=duration_profile,
|
|
dual_lord_profile=dual_lord_profile, source=SOURCE,
|
|
scope='first-cycle MD age-years only; caller supplies calculated Sree Lagna and consistent sidereal positions',
|
|
limits=['No AD/PD, second cycle or calendar dates',
|
|
'Duration and dual-lord profiles remain explicit, not silently merged',
|
|
'Supplied Sree Lagna is not recomputed', 'No independent event verification'])
|
|
|
|
|
|
def build_sudasa_first_cycle_report(**kwargs):
|
|
result = calculate_sudasa_first_cycle(**kwargs)
|
|
lines = ['# Sudasa — PVR first-cycle MD', '', result['scope'], '',
|
|
f'Source: {SOURCE}', f'Profile: {PROFILE}',
|
|
f'Duration profile: {result["duration_profile"]}; dual-lord profile: {result["dual_lord_profile"]}', '',
|
|
'| Sign | Full years | Remaining years | Start age | End age |',
|
|
'| --- | --- | --- | --- | --- |']
|
|
for row in result['periods']:
|
|
lines.append('| ' + ' | '.join(str(row[k]) for k in
|
|
('sign', 'full_years', 'years', 'start_age_years', 'end_age_years')) + ' |')
|
|
lines.extend(['', *result['limits']])
|
|
return dict(status=result['status'], customer_export_admitted=False,
|
|
evidence_appendix=[result], exports=dict(markdown='\n'.join(lines), ai_evidence_bundle=deepcopy(result)))
|
|
|
|
|
|
if __name__ == '__main__':
|
|
import argparse
|
|
import json
|
|
parser = argparse.ArgumentParser(description='Native Sudasa MD calendar with explicit cycle and fixed-year profiles')
|
|
parser.add_argument('--input', default='-')
|
|
parser.add_argument('--format', choices=('json', 'markdown'), default='json')
|
|
parser.add_argument('--year-basis', choices=('fixed', 'solar'), default='fixed')
|
|
parser.add_argument('--mode', choices=('birth-calendar', 'confirmed-md-ad'), default='birth-calendar')
|
|
options = parser.parse_args()
|
|
try:
|
|
text = sys.stdin.read() if options.input == '-' else Path(options.input).read_text(encoding='utf-8')
|
|
if options.mode == 'confirmed-md-ad':
|
|
if options.year_basis != 'solar':
|
|
raise ValueError('confirmed-md-ad requires explicit --year-basis solar')
|
|
builder = build_sudasa_anchored_antardasa_report
|
|
else:
|
|
builder = build_sudasa_calendar_report if options.year_basis == 'fixed' else build_sudasa_solar_calendar_report
|
|
report = builder(**json.loads(text))
|
|
except (ValueError, TypeError, OSError, OverflowError) as exc:
|
|
print(json.dumps(dict(status='blocked', reason='Invalid explicit Sudasa calendar request',
|
|
error_type=type(exc).__name__)), file=sys.stderr)
|
|
sys.exit(2)
|
|
print(report['exports']['markdown'] if options.format == 'markdown' else
|
|
json.dumps(report, ensure_ascii=False, allow_nan=False))
|