Files
Jyotisha/scripts/sudasa.py
T
ClaudeandClaude Opus 5.5 6a57faacdb feat(consult): run the astrologer's upstream skill in full (BUG-1696, BUG-1697, BUG-1698)
- 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
2026-11-07 14:02:26 +08:00

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))