diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..26dd8fd --- /dev/null +++ b/.env.production @@ -0,0 +1 @@ +VITE_API_BASE_URL=https://astro-engine-api-7ec0.onrender.com diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 20890b9..705bded 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -2,8 +2,9 @@ name: Deploy to GitHub Pages on: push: - branches: [main] - workflow_dispatch: {} + branches: + - DSK369-patch-1.5 + workflow_dispatch: permissions: contents: read @@ -17,25 +18,40 @@ concurrency: jobs: build: runs-on: ubuntu-latest + steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node + uses: actions/setup-node@v6 with: - node-version: 20 + node-version: 22 cache: npm - - run: npm ci - - run: npm run build - - uses: actions/configure-pages@v5 - - uses: actions/upload-pages-artifact@v3 + + - name: Install dependencies + run: npm install + + - name: Build + run: npm run build + + - name: Setup Pages + uses: actions/configure-pages@v5 + + - name: Upload artifact + uses: actions/upload-pages-artifact@v4 with: - path: dist + path: ./dist deploy: - needs: build - runs-on: ubuntu-latest environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} + + runs-on: ubuntu-latest + needs: build + steps: - - id: deployment + - name: Deploy + id: deployment uses: actions/deploy-pages@v4 diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..2400dd7 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +venv/ +.env diff --git a/backend/core/astro.py b/backend/core/astro.py new file mode 100644 index 0000000..ae74d6e --- /dev/null +++ b/backend/core/astro.py @@ -0,0 +1,54 @@ +RASHIS = [ + "Aries", "Taurus", "Gemini", "Cancer", + "Leo", "Virgo", "Libra", "Scorpio", + "Sagittarius", "Capricorn", "Aquarius", "Pisces" +] + +RASHI_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" +] + +NAKSHATRA_LORDS = [ + "Ketu", "Venus", "Sun", "Moon", "Mars", "Rahu", + "Jupiter", "Saturn", "Mercury" +] * 3 + + +def get_rashi(longitude): + index = int(longitude // 30) + rashi = RASHIS[index] + return rashi, RASHI_LORDS[rashi] + + +def get_nakshatra(longitude): + nak_index = int(longitude // (360 / 27)) + nakshatra = NAKSHATRAS[nak_index] + lord = NAKSHATRA_LORDS[nak_index] + return nakshatra, lord + + +def get_charan(longitude): + nak_fraction = (longitude % (360 / 27)) / (360 / 27) + charan = int(nak_fraction * 4) + 1 + return charan \ No newline at end of file diff --git a/backend/core/ayanamsa.py b/backend/core/ayanamsa.py new file mode 100644 index 0000000..53af73a --- /dev/null +++ b/backend/core/ayanamsa.py @@ -0,0 +1,76 @@ +import swisseph as swe + +# ============================== +# PREDEFINED CUSTOM +# ============================== +CUSTOM_KP_AYANAMSA = 23 + 44/60 + 18/3600 # 23:44:18 + + +# ============================== +# STANDARD MODES +# ============================== +AYANAMSA_MAP = { + "KP": swe.SIDM_KRISHNAMURTI, + "LAHIRI": swe.SIDM_LAHIRI, + "RAMAN": swe.SIDM_RAMAN, + "FAGAN": swe.SIDM_FAGAN_BRADLEY, +} + + +# ============================== +# HELPERS +# ============================== +def dms_to_decimal(deg, minute, second): + return deg + (minute / 60) + (second / 3600) + + +# ============================== +# MAIN SETTER +# ============================== +def set_ayanamsa(mode="KP", manual_value=None): + mode = mode.upper() + + # -------------------------- + # PREDEFINED CUSTOM KP + # -------------------------- + if mode == "CUSTOM_KP": + swe.set_sid_mode(swe.SIDM_USER, CUSTOM_KP_AYANAMSA) + + return { + "name": "Custom KP (23:44:18)", + "value_func": lambda jd: CUSTOM_KP_AYANAMSA + } + + # -------------------------- + # MANUAL INPUT + # -------------------------- + if mode == "CUSTOM_MANUAL": + if manual_value is None: + raise ValueError("Manual ayanamsa value required") + + swe.set_sid_mode(swe.SIDM_USER, manual_value) + + return { + "name": f"Custom Manual ({manual_value:.6f}°)", + "value_func": lambda jd: manual_value + } + + # -------------------------- + # STANDARD SWE MODES + # -------------------------- + if mode not in AYANAMSA_MAP: + raise ValueError(f"Unsupported ayanamsa: {mode}") + + swe.set_sid_mode(AYANAMSA_MAP[mode]) + + return { + "name": mode, + "value_func": swe.get_ayanamsa + } + + +# ============================== +# OPTIONAL: LIST MODES +# ============================== +def list_ayanamsa_modes(): + return list(AYANAMSA_MAP.keys()) + ["CUSTOM_KP", "CUSTOM_MANUAL"] \ No newline at end of file diff --git a/backend/core/cusps.py b/backend/core/cusps.py new file mode 100644 index 0000000..da439a4 --- /dev/null +++ b/backend/core/cusps.py @@ -0,0 +1,54 @@ +import swisseph as swe +from core.astro import get_rashi, get_nakshatra, get_charan +from core.utils import decimal_to_dms +from core.kp import compute_kp_levels + +# SAME OFFSET as houses.py / planets.py, kept consistent so cusps line up +# with planet/lagna longitudes computed elsewhere in core/. +AYAN_OFFSET = -0.1 + + +def calculate_placidus_cusps(jd, latitude, longitude): + houses, _ascmc = swe.houses(jd, latitude, longitude, b'P') + ayanamsa = swe.get_ayanamsa(jd) + + cusps = [] + for i in range(12): + tropical = houses[i] + sidereal = (tropical - ayanamsa + AYAN_OFFSET) % 360 + + rashi, rashi_lord = get_rashi(sidereal) + nakshatra, nak_lord = get_nakshatra(sidereal) + charan = get_charan(sidereal) + sub, sub_sub, sub_sub_sub = compute_kp_levels(sidereal, nak_lord) + + cusps.append({ + "house": i + 1, + "longitude": sidereal, + "degree": decimal_to_dms(sidereal), + + "rashi": rashi, + "rashi_lord": rashi_lord, + "nakshatra": nakshatra, + "nakshatra_lord": nak_lord, + "charan": charan, + + "sub_lord": sub, + "sub_sub_lord": sub_sub, + "sub_sub_sub_lord": sub_sub_sub, + }) + + return cusps + + +def get_placidus_house(longitude, cusp_longitudes): + """Which Placidus house a longitude falls in, given the 12 cusp start + longitudes. Matches lib/vedicTables.js: getPlacidusHouse exactly.""" + for i in range(12): + start = cusp_longitudes[i] + end = cusp_longitudes[(i + 1) % 12] + span = (end - start) % 360 + offset = (longitude - start) % 360 + if offset < span: + return i + 1 + return None diff --git a/backend/core/dasha.py b/backend/core/dasha.py new file mode 100644 index 0000000..51e2632 --- /dev/null +++ b/backend/core/dasha.py @@ -0,0 +1,187 @@ +from datetime import timedelta + +# ============================== +# BASE DATA +# ============================== +BASE_SEQUENCE = [ + "Ketu", "Venus", "Sun", "Moon", "Mars", + "Rahu", "Jupiter", "Saturn", "Mercury" +] + +DASHA_YEARS = { + "Ketu": 7, + "Venus": 20, + "Sun": 6, + "Moon": 10, + "Mars": 7, + "Rahu": 18, + "Jupiter": 16, + "Saturn": 19, + "Mercury": 17 +} + +TOTAL = 120 +NAKSHATRA_SIZE = 13 + (20 / 60) # 13°20' + + +# ============================== +# HELPERS +# ============================== +def get_sequence(start_lord): + idx = BASE_SEQUENCE.index(start_lord) + return BASE_SEQUENCE[idx:] + BASE_SEQUENCE[:idx] + + +def years_to_days(years): + return years * 365.2425 + + +def proportional_duration(parent_start, parent_end, factor): + total_seconds = (parent_end - parent_start).total_seconds() + return timedelta(seconds=total_seconds * factor) + + +# ============================== +# 🔥 CLASSICAL BALANCE (MINUTES BASED) +# ============================== +def calculate_mahadasha_balance(longitude, nak_lord): + longitude = longitude % 360 + + # Find Nakshatra start + nak_index = int(longitude // NAKSHATRA_SIZE) + nak_start = nak_index * NAKSHATRA_SIZE + + # 🔥 Convert to MINUTES (classical method) + elapsed_deg = longitude - nak_start + elapsed_minutes = elapsed_deg * 60 + + TOTAL_MINUTES = 800 # 13°20' = 800 minutes + + remaining_minutes = TOTAL_MINUTES - elapsed_minutes + remaining_fraction = remaining_minutes / TOTAL_MINUTES + + total_years = DASHA_YEARS[nak_lord] + + balance_years = remaining_fraction * total_years + + return nak_lord, balance_years + + +# ============================== +# MAHADASHA TIMELINE +# ============================== +def generate_mahadasha_timeline(start_lord, balance_years, start_dt): + timeline = [] + + balance_days = years_to_days(balance_years) + end_dt = start_dt + timedelta(days=balance_days) + + timeline.append({ + "lord": start_lord, + "start": start_dt, + "end": end_dt, + "years": balance_years + }) + + current_start = end_dt + sequence = get_sequence(start_lord) + + for lord in sequence[1:]: + years = DASHA_YEARS[lord] + duration = timedelta(days=years_to_days(years)) + + end = current_start + duration + + timeline.append({ + "lord": lord, + "start": current_start, + "end": end, + "years": years + }) + + current_start = end + + return timeline + + +# ============================== +# GENERIC SPLITTER (FORMULA BASED) +# ============================== +def split_dasha(parent_lord, parent_years, parent_start, parent_end): + results = [] + + sequence = get_sequence(parent_lord) + current_start = parent_start + + for lord in sequence: + # 🔥 Classical ratio + factor = DASHA_YEARS[lord] / TOTAL + + duration = proportional_duration(parent_start, parent_end, factor) + end = current_start + duration + + results.append({ + "lord": lord, + "start": current_start, + "end": end, + "years": parent_years * factor + }) + + current_start = end + + return results + + +# ============================== +# ANTARDASHA +# ============================== +def generate_antardasha(md_lord, md_years, md_start): + md_end = md_start + timedelta(days=years_to_days(md_years)) + return split_dasha(md_lord, md_years, md_start, md_end) + + +# ============================== +# PRATYANTAR +# ============================== +def generate_pratyantar(md_lord, ad_lord, ad_years, ad_start): + ad_end = ad_start + timedelta(days=years_to_days(ad_years)) + return split_dasha(ad_lord, ad_years, ad_start, ad_end) + + +# ============================== +# SUKSHMA +# ============================== +def generate_sukshma(md_lord, ad_lord, pd_lord, pd_years, pd_start): + pd_end = pd_start + timedelta(days=years_to_days(pd_years)) + return split_dasha(pd_lord, pd_years, pd_start, pd_end) + + +# ============================== +# PRANA +# ============================== +def generate_prana(md_lord, ad_lord, pd_lord, sd_lord, sd_years, sd_start): + sd_end = sd_start + timedelta(days=years_to_days(sd_years)) + return split_dasha(sd_lord, sd_years, sd_start, sd_end) + + +# ============================== +# FIND CURRENT +# ============================== +def find_current_dasha(dasha_list, current_dt): + for d in dasha_list: + if d["start"] <= current_dt < d["end"]: + return d + return dasha_list[-1] + + +# ============================== +# YEARS → Y/M/D +# ============================== +def convert_years_to_ymd(years): + total_days = int(years * 365.2425) + + y = total_days // 365 + m = (total_days % 365) // 30 + d = (total_days % 365) % 30 + + return y, m, d \ No newline at end of file diff --git a/backend/core/houses.py b/backend/core/houses.py new file mode 100644 index 0000000..14b1a86 --- /dev/null +++ b/backend/core/houses.py @@ -0,0 +1,64 @@ +import swisseph as swe +from core.astro import get_rashi, get_nakshatra, get_charan +from core.utils import decimal_to_dms +from core.kp import compute_kp_levels + +# SAME OFFSET +AYAN_OFFSET = -0.1 + + +def calculate_lagna(jd, latitude, longitude): + houses, ascmc = swe.houses(jd, latitude, longitude) + + asc_tropical = ascmc[0] + + ayanamsa = swe.get_ayanamsa(jd) + + asc_sidereal = (asc_tropical - ayanamsa) % 360 + + # APPLY OFFSET + asc_sidereal = (asc_sidereal + AYAN_OFFSET) % 360 + + rashi, rashi_lord = get_rashi(asc_sidereal) + nakshatra, nak_lord = get_nakshatra(asc_sidereal) + charan = get_charan(asc_sidereal) + + # 🔥 KP LEVELS ADDED + sub, sub_sub, sub_sub_sub = compute_kp_levels(asc_sidereal, nak_lord) + + return { + "planet": "Lagna", + "longitude": asc_sidereal, + "degree": decimal_to_dms(asc_sidereal), + "latitude": 0, + "speed": 0, + "retrograde": False, + + "rashi": rashi, + "rashi_lord": rashi_lord, + "nakshatra": nakshatra, + "nakshatra_lord": nak_lord, + "charan": charan, + + "sub_lord": sub, + "sub_sub_lord": sub_sub, + "sub_sub_sub_lord": sub_sub_sub + } + + +def assign_houses(planets, lagna_rashi): + from core.astro import RASHIS + + lagna_index = RASHIS.index(lagna_rashi) + + house_map = {} + + for i in range(12): + house_number = i + 1 + rashi_index = (lagna_index + i) % 12 + house_map[RASHIS[rashi_index]] = house_number + + for p in planets: + p["house"] = house_map[p["rashi"]] + + return planets, house_map \ No newline at end of file diff --git a/backend/core/jd.py b/backend/core/jd.py new file mode 100644 index 0000000..ae383fa --- /dev/null +++ b/backend/core/jd.py @@ -0,0 +1,30 @@ +import swisseph as swe +from datetime import datetime + +try: + from zoneinfo import ZoneInfo +except ImportError: + from backports.zoneinfo import ZoneInfo + + +def to_julian_day(date_str, time_str, timezone_str): + dt_str = f"{date_str} {time_str}" + local_dt = datetime.strptime(dt_str, "%Y-%m-%d %H:%M:%S") + + local_dt = local_dt.replace(tzinfo=ZoneInfo(timezone_str)) + utc_dt = local_dt.astimezone(ZoneInfo("UTC")) + + hour = ( + utc_dt.hour + + utc_dt.minute / 60 + + utc_dt.second / 3600 + ) + + jd = swe.julday( + utc_dt.year, + utc_dt.month, + utc_dt.day, + hour + ) + + return jd \ No newline at end of file diff --git a/backend/core/kp.py b/backend/core/kp.py new file mode 100644 index 0000000..47d5b08 --- /dev/null +++ b/backend/core/kp.py @@ -0,0 +1,66 @@ +# Vimshottari base sequence +BASE_SEQUENCE = [ + "Ketu", "Venus", "Sun", "Moon", "Mars", + "Rahu", "Jupiter", "Saturn", "Mercury" +] + +DASHA_YEARS = { + "Ketu": 7, + "Venus": 20, + "Sun": 6, + "Moon": 10, + "Mars": 7, + "Rahu": 18, + "Jupiter": 16, + "Saturn": 19, + "Mercury": 17 +} + +TOTAL = 120 +NAKSHATRA_SIZE = 360 / 27 + + +def get_kp_sequence(lord): + idx = BASE_SEQUENCE.index(lord) + return BASE_SEQUENCE[idx:] + BASE_SEQUENCE[:idx] + + +def get_lord_from_value(value, sequence): + cumulative = 0 + for lord in sequence: + years = DASHA_YEARS[lord] + if cumulative <= value < cumulative + years: + return lord, cumulative, years + cumulative += years + return sequence[-1], cumulative, DASHA_YEARS[sequence[-1]] + + +def compute_kp_levels(longitude, nakshatra_lord): + longitude = longitude % 360 + + nak_index = int(longitude // NAKSHATRA_SIZE) + nak_start = nak_index * NAKSHATRA_SIZE + offset = longitude - nak_start + + level1_value = (offset / NAKSHATRA_SIZE) * TOTAL + + seq1 = get_kp_sequence(nakshatra_lord) + sub, cum1, yrs1 = get_lord_from_value(level1_value, seq1) + + level2 = ((level1_value - cum1) / yrs1) * TOTAL + + seq2 = get_kp_sequence(sub) + sub_sub, cum2, yrs2 = get_lord_from_value(level2, seq2) + + level3 = ((level2 - cum2) / yrs2) * TOTAL + + seq3 = get_kp_sequence(sub_sub) + sub_sub_sub, _, _ = get_lord_from_value(level3, seq3) + + return sub, sub_sub, sub_sub_sub + + +# 🔥 BACKWARD COMPATIBILITY (DO NOT REMOVE OLD CODE USAGE) +def get_sub_lord(longitude, nakshatra_lord): + sub, _, _ = compute_kp_levels(longitude, nakshatra_lord) + return sub \ No newline at end of file diff --git a/backend/core/panchang.py b/backend/core/panchang.py new file mode 100644 index 0000000..31044c8 --- /dev/null +++ b/backend/core/panchang.py @@ -0,0 +1,75 @@ +# Panchang (Tithi, Var, Nakshatra, Yoga, Karana) computation. +# +# Not part of the original core/ module set — Astro-Engine-v2.0's mock +# data explicitly marked this as unimplemented (see mockChartData.js: +# MOCK_PANCHANG, "Panchang is NOT computed by the backend yet"). Added +# here using the standard Panchang formulas so real (non-mock) birth +# data gets a real Panchang rather than always showing the one sample +# chart's reference values regardless of input. +# +# All five limbs derive from Sun/Moon sidereal longitude and the local +# birth date — no additional ephemeris lookups beyond what get_all_planets +# already computes. + +TITHI_NAMES = [ + "Pratipada", "Dwitiya", "Tritiya", "Chaturthi", "Panchami", "Shashthi", + "Saptami", "Ashtami", "Navami", "Dashami", "Ekadashi", "Dwadashi", + "Trayodashi", "Chaturdashi", +] + +YOGA_NAMES = [ + "Vishkambha", "Priti", "Ayushman", "Saubhagya", "Shobhana", "Atiganda", + "Sukarma", "Dhriti", "Shoola", "Ganda", "Vriddhi", "Dhruva", "Vyaghata", + "Harshana", "Vajra", "Siddhi", "Vyatipata", "Variyana", "Parigha", + "Shiva", "Siddha", "Sadhya", "Shubha", "Shukla", "Brahma", "Indra", + "Vaidhriti", +] + +MOVABLE_KARANAS = ["Bava", "Balava", "Kaulava", "Taitila", "Gara", "Vanija", "Vishti"] + + +def get_tithi(sun_lon, moon_lon): + diff = (moon_lon - sun_lon) % 360 + tithi_num = int(diff // 12) + 1 # 1..30 + + if tithi_num <= 15: + paksha, idx = "Shukla", tithi_num + name = "Purnima" if idx == 15 else TITHI_NAMES[idx - 1] + else: + paksha, idx = "Krishna", tithi_num - 15 + name = "Amavasya" if idx == 15 else TITHI_NAMES[idx - 1] + + return { + "number": tithi_num, + "paksha": paksha, + "name": name, + "label": f"{paksha} {idx} ({name})", + } + + +def get_yoga(sun_lon, moon_lon): + total = (sun_lon + moon_lon) % 360 + idx = int(total // (360 / 27)) + return YOGA_NAMES[idx] + + +def get_karana(sun_lon, moon_lon): + diff = (moon_lon - sun_lon) % 360 + half_tithi = int(diff // 6) # 0..59 + + if half_tithi == 0: + return "Kimstughna" + if 1 <= half_tithi <= 56: + return MOVABLE_KARANAS[(half_tithi - 1) % 7] + return ["Shakuni", "Chatushpada", "Naga"][half_tithi - 57] + + +def compute_panchang(sun_lon, moon_lon, moon_nakshatra, birth_dt): + tithi = get_tithi(sun_lon, moon_lon) + return { + "tithi": tithi["label"], + "var": birth_dt.strftime("%A"), + "nakshatra": moon_nakshatra, + "yog": get_yoga(sun_lon, moon_lon), + "karana": get_karana(sun_lon, moon_lon), + } diff --git a/backend/core/planets.py b/backend/core/planets.py new file mode 100644 index 0000000..b11d91f --- /dev/null +++ b/backend/core/planets.py @@ -0,0 +1,99 @@ +import swisseph as swe +from core.astro import get_rashi, get_nakshatra, get_charan +from core.utils import decimal_to_dms +from core.kp import get_sub_lord +from core.kp import compute_kp_levels + +FLAGS = swe.FLG_SWIEPH | swe.FLG_SIDEREAL | swe.FLG_SPEED +AYAN_OFFSET = -0.1 + +PLANETS = { + "Sun": swe.SUN, + "Moon": swe.MOON, + "Mercury": swe.MERCURY, + "Venus": swe.VENUS, + "Mars": swe.MARS, + "Jupiter": swe.JUPITER, + "Saturn": swe.SATURN, + "Uranus": swe.URANUS, + "Neptune": swe.NEPTUNE, + "Pluto": swe.PLUTO, +} + + +def calculate_planet(jd, name): + result = swe.calc_ut(jd, PLANETS[name], FLAGS) + + lon = (result[0][0] + AYAN_OFFSET) % 360 + lat = result[0][1] + speed = result[0][3] + + rashi, rashi_lord = get_rashi(lon) + nak, nak_lord = get_nakshatra(lon) + charan = get_charan(lon) + + sub, sub_sub, sub_sub_sub = compute_kp_levels(lon, nak_lord) + + return { + "planet": name, + "longitude": lon, + "degree": decimal_to_dms(lon), + "latitude": lat, + "speed": speed, + "retrograde": speed < 0, + + "rashi": rashi, + "rashi_lord": rashi_lord, + "nakshatra": nak, + "nakshatra_lord": nak_lord, + "charan": charan, + + "sub_lord": sub, + "sub_sub_lord": sub_sub, + "sub_sub_sub_lord": sub_sub_sub + } + + +def calculate_rahu_ketu(jd, true_node=False): + node = swe.TRUE_NODE if true_node else swe.MEAN_NODE + result = swe.calc_ut(jd, node, FLAGS) + + rahu_lon = (result[0][0] + AYAN_OFFSET) % 360 + speed = result[0][3] + + ketu_lon = (rahu_lon + 180) % 360 + + def build(name, lon, sp): + rashi, rashi_lord = get_rashi(lon) + nak, nak_lord = get_nakshatra(lon) + charan = get_charan(lon) + + sub, sub_sub, sub_sub_sub = compute_kp_levels(lon, nak_lord) + + return { + "planet": name, + "longitude": lon, + "degree": decimal_to_dms(lon), + "latitude": 0, + "speed": sp, + "retrograde": True, + + "rashi": rashi, + "rashi_lord": rashi_lord, + "nakshatra": nak, + "nakshatra_lord": nak_lord, + "charan": charan, + + "sub_lord": sub, + "sub_sub_lord": sub_sub, + "sub_sub_sub_lord": sub_sub_sub + } + + return build("Rahu", rahu_lon, speed), build("Ketu", ketu_lon, -speed) + + +def get_all_planets(jd, true_node=False): + res = [calculate_planet(jd, p) for p in PLANETS] + r, k = calculate_rahu_ketu(jd, true_node) + res.extend([r, k]) + return res \ No newline at end of file diff --git a/backend/core/ruling_planets.py b/backend/core/ruling_planets.py new file mode 100644 index 0000000..fea36fa --- /dev/null +++ b/backend/core/ruling_planets.py @@ -0,0 +1,21 @@ +# Traditional 5 Ruling Planets. Ported from lib/vedicTables.js: +# computeRulingPlanets — see core/significators.py's header for why the +# JS/Python versions match field-for-field. + +# Python's datetime.weekday(): 0=Monday .. 6=Sunday (unlike JS's +# Date.getDay() where 0=Sunday). Index accordingly. +WEEKDAY_LORDS = ["Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Sun"] + + +def get_day_lord(birth_dt): + return WEEKDAY_LORDS[birth_dt.weekday()] + + +def compute_ruling_planets(lagna, moon, birth_dt): + return { + "lagna_lord": lagna["rashi_lord"], + "lagna_star_lord": lagna["nakshatra_lord"], + "rasi_lord": moon["rashi_lord"], + "day_lord": get_day_lord(birth_dt), + "moon_star_lord": moon["nakshatra_lord"], + } diff --git a/backend/core/significators.py b/backend/core/significators.py new file mode 100644 index 0000000..cbc5e01 --- /dev/null +++ b/backend/core/significators.py @@ -0,0 +1,40 @@ +# KP 4-step significators per house. Ported from the frontend's +# lib/vedicTables.js: computeSignificators (that JS port was itself +# written to match this module's intended design before this file +# existed — see Astro-Engine-v2.0's mockChartData.js comments). Kept in +# sync deliberately: same 4-group breakdown, same field names once +# translated snake_case <-> camelCase at the API boundary. +def compute_significators(placements, cusps): + results = [] + + for cusp in cusps: + house_num = cusp["house"] + owner = cusp["rashi_lord"] + + occupants = [p["planet"] for p in placements if p.get("placidus_house") == house_num] + + occupant_stars = sorted(set( + p["planet"] for p in placements + if p["nakshatra_lord"] in occupants and p["planet"] not in occupants + )) + + owner_placement = next((p for p in placements if p["planet"] == owner), None) + owner_star_lord = owner_placement["nakshatra_lord"] if owner_placement else None + + owner_stars = sorted(set( + p["planet"] for p in placements + if owner_placement and p["nakshatra_lord"] == owner and p["planet"] != owner + )) + + results.append({ + "house": house_num, + "cusp_rashi": cusp["rashi"], + "owner": owner, + "owner_star_lord": owner_star_lord, + "step_a_star_of_occupants": occupant_stars, + "step_b_occupants": occupants, + "step_c_star_of_owner": owner_stars, + "step_d_owner": [owner] if owner else [], + }) + + return results diff --git a/backend/core/utils.py b/backend/core/utils.py new file mode 100644 index 0000000..c5ecf96 --- /dev/null +++ b/backend/core/utils.py @@ -0,0 +1,33 @@ +def decimal_to_dms(decimal_degree): + """ + Convert longitude → Rashi-relative DMS (0–30°) + + Seconds are shown as a whole number. Only the display is rounded — + every calculation downstream (sub-lords, cusps, significators) reads + the raw float longitude, never this string. + """ + + decimal_degree = decimal_degree % 360 + degree_in_sign = decimal_degree % 30 + + degrees = int(degree_in_sign) + + minutes_full = (degree_in_sign - degrees) * 60 + minutes = int(minutes_full) + + seconds = round((minutes_full - minutes) * 60) + + # Rounding can tip 59.6" up to a full 60" — carry it rather than + # printing an invalid 60. A sign spans [0°, 30°), so 30° wraps to 0°. + if seconds == 60: + seconds = 0 + minutes += 1 + if minutes == 60: + minutes = 0 + degrees += 1 + if degrees == 30: + degrees = 0 + + return f"{degrees:02d}° {minutes:02d}' {seconds:02d}\"" + + diff --git a/backend/ephemeris/semo_18.se1 b/backend/ephemeris/semo_18.se1 new file mode 100644 index 0000000..5427d9f Binary files /dev/null and b/backend/ephemeris/semo_18.se1 differ diff --git a/backend/ephemeris/sepl_18.se1 b/backend/ephemeris/sepl_18.se1 new file mode 100644 index 0000000..786702c Binary files /dev/null and b/backend/ephemeris/sepl_18.se1 differ diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..07a02d8 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.141.1 +uvicorn[standard]==0.52.3 +pydantic==2.13.4 +pyswisseph==2.10.3.2 +tzdata diff --git a/backend/services/__init__.py b/backend/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/app.py b/backend/services/app.py new file mode 100644 index 0000000..d8e90e7 --- /dev/null +++ b/backend/services/app.py @@ -0,0 +1,173 @@ +# FastAPI wrapper around core/. Exposes POST /chart with the exact +# response shape Astro-Engine-v2.0's frontend already expects (see that +# repo's src/lib/api.js and src/data/mockChartData.js) so switching the +# frontend from mock to real data is just USE_MOCK = false, no component +# changes. +# +# core/ itself is snake_case (Python convention); this file is the one +# place that translates to the camelCase shape the frontend consumes. +# +# Run: venv\Scripts\uvicorn services.app:app --reload --port 8000 +import os +from datetime import datetime + +import swisseph as swe +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel + +from core.jd import to_julian_day +from core.planets import get_all_planets +from core.houses import calculate_lagna, assign_houses +from core.cusps import calculate_placidus_cusps, get_placidus_house +from core.significators import compute_significators +from core.ruling_planets import compute_ruling_planets +from core.panchang import compute_panchang +from core.ayanamsa import set_ayanamsa + +EPHE_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "ephemeris") +swe.set_ephe_path(EPHE_PATH) + +app = FastAPI(title="Astro Engine API") + +# The Vite dev server's port drifts (5173/5174/5175...) whenever one is +# already taken, so localhost stays wildcarded; the deployed frontend +# origin is pinned explicitly. +app.add_middleware( + CORSMiddleware, + allow_origin_regex=r"^https://dsk369\.github\.io$|^http://localhost:\d+$|^http://127\.0\.0\.1:\d+$", + allow_methods=["*"], + allow_headers=["*"], +) + + +class LocationIn(BaseModel): + city: str | None = None + state: str | None = None + country: str | None = None + lat: float + lon: float + tz: str + label: str | None = None + + +class ChartRequest(BaseModel): + firstName: str | None = None + fatherName: str | None = None + lastName: str | None = None + dob: str # "YYYY-MM-DD" + tob: str # "HH:MM:SS" + location: LocationIn + ayanamsa: str = "KP" + customAyanamsaValue: str | float | None = None + chartStyle: str | None = None + rahuNode: str = "mean" # "mean" | "true" + houseSystem: str | None = None + + +def _to_camel_placement(p): + return { + "planet": p["planet"], + "longitude": p["longitude"], + "degree": p["degree"], + "retrograde": p["retrograde"], + "rashi": p["rashi"], + "rashiLord": p["rashi_lord"], + "nakshatra": p["nakshatra"], + "nakshatraLord": p["nakshatra_lord"], + "charan": p["charan"], + "subLord": p["sub_lord"], + "subSubLord": p["sub_sub_lord"], + "subSubSubLord": p["sub_sub_sub_lord"], + "house": p["house"], + } + + +def _to_camel_cusp(c): + return { + "house": c["house"], + "longitude": c["longitude"], + "degree": c["degree"], + "rashi": c["rashi"], + "rashiLord": c["rashi_lord"], + "nakshatra": c["nakshatra"], + "nakshatraLord": c["nakshatra_lord"], + "charan": c["charan"], + "subLord": c["sub_lord"], + "subSubLord": c["sub_sub_lord"], + "subSubSubLord": c["sub_sub_sub_lord"], + } + + +def _to_camel_significator(s): + return { + "house": s["house"], + "cuspRashi": s["cusp_rashi"], + "owner": s["owner"], + "ownerStarLord": s["owner_star_lord"], + "stepAStarOfOccupants": s["step_a_star_of_occupants"], + "stepBOccupants": s["step_b_occupants"], + "stepCStarOfOwner": s["step_c_star_of_owner"], + "stepDOwner": s["step_d_owner"], + } + + +def _to_camel_ruling(r): + return { + "lagnaLord": r["lagna_lord"], + "lagnaStarLord": r["lagna_star_lord"], + "rasiLord": r["rasi_lord"], + "dayLord": r["day_lord"], + "moonStarLord": r["moon_star_lord"], + } + + +@app.post("/chart") +def post_chart(req: ChartRequest): + jd = to_julian_day(req.dob, req.tob, req.location.tz) + + manual_value = float(req.customAyanamsaValue) if req.customAyanamsaValue else None + set_ayanamsa(req.ayanamsa, manual_value) + + true_node = req.rahuNode == "true" + planets_raw = get_all_planets(jd, true_node) + + lagna_raw = calculate_lagna(jd, req.location.lat, req.location.lon) + + all_raw = [lagna_raw] + planets_raw + all_raw, _house_map = assign_houses(all_raw, lagna_raw["rashi"]) + + cusps_raw = calculate_placidus_cusps(jd, req.location.lat, req.location.lon) + cusp_longitudes = [c["longitude"] for c in cusps_raw] + for p in all_raw: + p["placidus_house"] = get_placidus_house(p["longitude"], cusp_longitudes) + + significators_raw = compute_significators(all_raw, cusps_raw) + + moon_raw = next(p for p in all_raw if p["planet"] == "Moon") + sun_raw = next(p for p in all_raw if p["planet"] == "Sun") + + birth_dt = datetime.strptime(f"{req.dob} {req.tob}", "%Y-%m-%d %H:%M:%S") + ruling_raw = compute_ruling_planets(lagna_raw, moon_raw, birth_dt) + panchang = compute_panchang(sun_raw["longitude"], moon_raw["longitude"], moon_raw["nakshatra"], birth_dt) + + all_placements = [_to_camel_placement(p) for p in all_raw] + lagna_out = all_placements[0] + planets_out = all_placements[1:] + + return { + "lagna": lagna_out, + "planets": planets_out, + "allPlacements": all_placements, + "cusps": [_to_camel_cusp(c) for c in cusps_raw], + "significators": [_to_camel_significator(s) for s in significators_raw], + "rulingPlanets": _to_camel_ruling(ruling_raw), + "panchang": {**panchang, "_mock": False}, + "summary": { + "lagnaRashi": lagna_raw["rashi"], + "moonRashi": moon_raw["rashi"], + "moonNakshatra": moon_raw["nakshatra"], + "moonCharan": moon_raw["charan"], + }, + "_mock": False, + } diff --git a/package-lock.json b/package-lock.json index 95cf9b0..0dc957d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,35 +19,10 @@ "vite": "^8.1.1" } }, - "node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", "dev": true, "license": "MIT", "optional": true, @@ -630,6 +605,40 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@rolldown/binding-win32-arm64-msvc": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", diff --git a/render.yaml b/render.yaml new file mode 100644 index 0000000..b723565 --- /dev/null +++ b/render.yaml @@ -0,0 +1,11 @@ +services: + - type: web + name: astro-engine-api + runtime: python + rootDir: backend + plan: free + buildCommand: pip install -r requirements.txt + startCommand: uvicorn services.app:app --host 0.0.0.0 --port $PORT + envVars: + - key: PYTHON_VERSION + value: 3.11.9 diff --git a/src/App.css b/src/App.css index 0e9b2e5..7a8fa4e 100644 --- a/src/App.css +++ b/src/App.css @@ -11,6 +11,10 @@ } .app-header { + display: flex; + align-items: center; + justify-content: center; + position: relative; text-align: center; padding: var(--space-4) 0 0; } @@ -20,6 +24,24 @@ font-size: 0.95em; } +.app-header__lang-toggle { + position: absolute; + right: 0; + top: var(--space-4); + padding: var(--space-2) var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-surface); + color: var(--color-text); + cursor: pointer; + font-size: 0.9em; + min-height: 36px; +} + +.app-header__lang-toggle:hover { + background: var(--color-accent-light); +} + .app-main { display: flex; flex-direction: column; diff --git a/src/App.jsx b/src/App.jsx index 81b3e55..ae19c74 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -10,9 +10,11 @@ import SignificatorTable from "./components/SignificatorTable"; import NorthIndianChart from "./components/charts/NorthIndianChart"; import SouthIndianChart from "./components/charts/SouthIndianChart"; import { fetchChart } from "./lib/api"; +import { useLanguage } from "./lib/language"; import "./App.css"; function App() { + const { t, lang, toggleLang } = useLanguage(); const [chartData, setChartData] = useState(null); const [submittedForm, setSubmittedForm] = useState(null); const [chartStyle, setChartStyle] = useState("north"); @@ -39,8 +41,13 @@ function App() { return (
Vedic & KP birth chart calculator
+{t("appSubtitle")}
+