withAshoka a Vedic Companion

← Return to Our Approach

Technical Notes — How withAshoka Calculates a Chart

This is the engineering companion to Our Approach. Where the Approach page tells a knowledgeable student what we do, this page shows how — formulae, flag values, file pointers, edge cases. It is written for developers, auditors, and Jyotishis who want to verify the maths against their own implementation.

All path references are relative to the repository root. The active implementation is src/implementations/ashoka/backend/.


Stack

LayerChoice
EphemerisSwiss Ephemeris via pyswisseph 2.10.3.2
Ephemeris filessemo_18.se1, sepl_18.se1, seas_18.se1 (cover 1800–2400 CE)
AyanamsaLahiri / Chitrapaksha (SIDM_LAHIRI), applied manually
House systemWhole-Sign (Sampoorna Bhava), with Bhava Chalit (equal-house) and Placidus also computed
Node conventionMean node (swe.MEAN_NODE); Ketu = Rahu + 180°
Time baseJulian Day (UT) computed from local civil time + IANA timezone

Planet calculations request geocentric tropical positions plus daily motion from the Swiss Ephemeris (the .se1 files cover 1800–2400 CE), then subtract the ayanamsa ourselves to obtain sidereal longitudes.


Lagna (Ascendant)

The Lagna is the most error-prone calculation in any Jyotish stack. We compute the ascendant in pure Python rather than delegating to pyswisseph's house functions — this keeps every step transparent and auditable end-to-end and side-steps a global-state pitfall in the library's sidereal house routines. The pipeline:

1. Greenwich Mean Sidereal Time (IAU 1982). Given Julian Day jd:

T            = (jd - 2451545.0) / 36525.0
gmst_seconds = 24110.54841
             + 8640184.812866 · T
             + 0.093104       · T²
             - 6.2e-6         · T³
gmst_degrees = (gmst_seconds / 240 + 180) mod 360

2. Apparent Right Medium Coeli. With east longitude lng in degrees:

ARMC = (gmst_degrees + lng) mod 360

3. Tropical ascendant. Using the obliquity of the ecliptic ε from swe.calc_ut(jd, swe.ECL_NUT, 0):

ASC_tropical = atan2(
    cos(ARMC),
    -( sin(ARMC)·cos(ε) + tan(φ)·sin(ε) )
) mod 360

where φ is geographic latitude in radians.

4. Sidereal ascendant. ASC_sidereal = (ASC_tropical − ayanamsa) mod 360, where ayanamsa = swe.get_ayanamsa_ut(jd) after swe.set_sid_mode(SIDM_LAHIRI).

5. Lagna sign + degree. The 30° band that contains ASC_sidereal is the Lagna sign; the offset within that band is the Lagna degree.

Reference implementation: src/implementations/ashoka/backend/app/services/jyotish/chart.py:86-101.

The function returns a ChartDebug payload — julian_day, raw_armc, tropical_asc, ayanamsa, sidereal_asc — so any output is reproducible and auditable end-to-end.


Planet positions

For each graha:

xx, _   = swe.calc_ut(jd, planet_id, swe.FLG_SPEED)
trop    = xx[0] mod 360
sid     = (trop − ayanamsa) mod 360
retro   = xx[3] < 0

Graha → planet ID mapping uses swe.MEAN_NODE for Rahu. Ketu is derived as (rahu_lon + 180) mod 360 and is always treated as retrograde.

Combust (asta). BPHS orbs in degrees, separately for direct and retrograde motion. Sun, Rahu, and Ketu cannot be combust.

GrahaDirectRetro
Chandra1212
Mangal1717
Budha1412
Guru1111
Shukra108
Shani1515

Nakshatra and pada. Each nakshatra spans 360/27 ≈ 13.333°; each pada spans 360/108 ≈ 3.333°. Pada index within the nakshatra is int((lon mod nak_span) / pada_span) + 1.


Houses

We compute three house systems in parallel and use them for different purposes:

  1. Whole-Sign (Sampoorna Bhava) — primary. The Lagna sign is the first house, the next sign the second, and so on. This is the Parashara default and the system Ashoka's reading is keyed off.
  2. Bhava Chalit (equal-house, 30° from exact Lagna) — used to flag planets that are in one whole-sign house but, by exact longitude, fall into the next or previous house. Useful when the Lagna degree is near a sign boundary.
  3. Placidus — included for cross-reference with Western tools.

Divisional charts (Vargas)

Each varga is computed by mapping a planet's longitude λ (sidereal) into a divisional sign. We implement the BPHS Parashara method throughout.

ChartDivisionMethod
D1 Rāśiλ directly
D7 SaptamśaEqual 7-fold split per sign, parity of source sign decides starting sign
D9 NavāmśaEqual 9-fold split, starts from sign of same element (movable/fixed/dual)
D10 Daśāmśa10×Equal 10-fold split, odd signs from self, even from 9th
D12 Dvādaśāmśa12×Equal 12-fold split, starts from the sign itself
D16 Ṣoḍaśāmśa16×Equal 16-fold split, starts from Aries / Leo / Sagittarius depending on movable / fixed / dual
D20 Vimśāmśa20×Equal 20-fold split, similar starting rule
D30 TriṃśāmśaunequalBPHS unequal division — 5°/5°/8°/7°/5° per sign with fixed lordship per slot
D60 Ṣaṣṭyāmśa60×Equal 0.5° divisions, named per BPHS

The D30 deserves emphasis: many implementations use a simplified equal 6-division scheme. We implement the unequal BPHS scheme — Mars, Saturn, Jupiter, Mercury, Venus get fixed degree-bands (5/5/8/7/5°) within each 30° sign, and odd vs. even signs reverse the order.

The D60 advances every 0.5° of source longitude, i.e. roughly every two minutes of clock time. We surface a warning to the user when the recorded birth time is rated less than "exact" before exposing D60 outputs.


Shadbala

Six classical strengths per planet, summed per BPHS:

  • Sthana Bala — positional (uccha, saptavargaja, ojha-yugma, kendra, drekkana)
  • Dig Bala — directional (planet's preferred kendra)
  • Kala Bala — temporal (nathonatha, paksha, tribhaga, abda/maasa/vaara/ hora, ayana, yuddha)
  • Cheshta Bala — motional (proper motion vs. mean motion)
  • Naisargika Bala — natural strength (fixed table per BPHS)
  • Drig Bala — aspectual

Shadbala scores are computed for all seven classical planets. Rahu and Ketu are excluded from Shadbala per Parashara convention. Output units are virupas (1 rūpa = 60 virupas); display in the chart UI normalises against the BPHS minimum-strength thresholds per planet.


Ashtakavarga

We compute the full Ashtakavarga system:

  • Bhinnāṣṭakavarga — per planet, the bindu (point) contribution from each of the eight contributors (the seven classical planets + the Lagna) to each of the twelve signs. Sums to a 12-vector of bindus per planet.
  • Sarvāṣṭakavarga — sum of the seven planetary bhinna vectors, giving the cumulative strength of each sign.

The Kakshya contributor table — which contributor adds a bindu in which sign relative to the source planet — is taken from BPHS and applied directly.

Per-Kakshya (sub-division) breakdowns are computed but not yet exposed in the chart UI; rollout is staged with the divisional charts.


Dasha

Vimshottari (120-year cycle, primary). Mahadasha lord is determined from the natal Moon's nakshatra; the balance at birth is (nakshatra_span − moon_offset_in_nakshatra) / nakshatra_span × dasha_years. We compute Mahadasha → Antardasha → Pratyantardasha for the user's lifetime and identify the active level for the reading prompt.

Yogini (36-year cycle). Nakshatra-based, with the eight Yoginis cycling through the nakshatra series. Calculated; display rolling out.

Chara (Jaimini, sign-based). Lordship of the sign cycles per Jaimini rules. Calculated; display rolling out.


Yogas

The following yogas are detected algorithmically from chart data:

  • Raj Yoga (kendra-trikoṇa lord conjunction or mutual aspect)
  • Dhana Yoga (2nd / 11th lord configurations)
  • Pancha Mahapurusha — Ruchaka, Bhadra, Hamsa, Malavya, Sasa
  • Gaja-Kesari (Jupiter in kendra from Moon)
  • Viparita Raja Yoga (3rd / 6th / 8th / 12th lord interchanges)
  • Neecha Bhanga Raja Yoga (debilitation cancellation, see below)
  • Kemadruma (Moon isolation)

When a significant yoga is present its label and definition are passed to the reading prompt so Ashoka references it explicitly. The library is expected to grow over time; absence of a yoga in this list is an implementation gap, not a denial of the yoga.


Neecha Bhanga (debilitation cancellation)

A debilitated planet's debilitation is treated as cancelled if any of the four BPHS conditions hold:

  1. The lord of the debilitation sign is in a kendra from the Lagna or Moon.
  2. The planet that would be exalted in the debilitation sign is in a kendra from the Lagna or Moon.
  3. The dispositor of the debilitated planet is exalted.
  4. The debilitated planet is conjunct or aspected by its dispositor or by the planet exalted in that sign.

When any condition is satisfied we annotate the planet as Neecha Bhanga and expose the cancellation reason; the reading then treats the planet's effect per the Neecha Bhanga Raja Yoga rather than as a straight debilitation.


Panchang

Computed in real time from the user's location, not pulled from an almanac. The five elements are computed at sunrise (udaya) to give the day's primary values — the conventional panchang reckoning, and the same instant our calendar engine (event_scanner.py) uses, so the /panchang and /calendar pages agree on a day's limbs. Intra-day transitions and the muhūrta windows are computed separately. All longitudes are sidereal (Lahiri). (Until #150 the headline was sampled at solar noon, which diverged from Drik whenever a tithi/nakshatra boundary fell between sunrise and noon.)

ElementComputation
Tithi((Moon_lon − Sun_lon) mod 360) / 12 → index 0–29 (Shukla 0–14, Krishna 15–29)
Vara (weekday)Day of week from local civil date — Sun=0 .. Sat=6 in Sanskrit ordering
NakshatraMoon_lon / (360/27) → index 0–26, plus pada within nakshatra
Yoga (Panchang)((Sun_lon + Moon_lon) mod 360) / (360/27) → index 0–26
Karana((Moon_lon − Sun_lon) mod 360) / 6 → index 0–59; 7 moveable karanas cycle 56 times then 4 fixed karanas close the lunation

Sun & Moon — rise / set / solar noon

swe.rise_trans(jd_search, body, rsmi, geopos) for body ∈ {SUN, MOON} and rsmi ∈ {CALC_RISE, CALC_SET, CALC_MTRANSIT}. Search starts at local midnight so the same-day events are caught even when sunrise precedes 06:00. Moon events that fall on the following local date (when the Moon already rose/set before today's midnight) are clamped to null.

Inauspicious time windows — Rahu Kaal, Yamaganda, Gulika

Each is a single segment of the eight-fold division of the daylight span (sunset − sunrise). The weekday-indexed segment number is fixed by BPHS:

Weekday (Mon=0..Sun=6)Rahu KaalYamagandaGulika
Mon246
Tue735
Wed524
Thu613
Fri472
Sat361
Sun857

Window = [sunrise + (seg − 1) · daylight/8, sunrise + seg · daylight/8]. All three rows are verified to the minute against Drik Panchang. (The Yamaganda row was off by one segment on every weekday until #150; the corrected values above replace the earlier table.)

Abhijit Muhurta

The 8th of fifteen equal daytime muhūrtas: [sunrise + 7·D/15, sunrise + 8·D/15] where D = sunset − sunrise (so the window is ~48–54 min and straddles solar noon, widening with daylight length). This matches Drik Panchang's definition; an earlier fixed solar-noon ± 12 min (24 min) window was centred correctly but materially narrow, corrected in #150.

Choghadiya — day & night

Eight day periods (sunrise → sunset) and eight night periods (sunset → next sunrise), each labelled by name (Amrit / Shubh / Labh / Char / Udveg / Rog / Kaal — seven unique names cycling, slot 8 repeats slot 1), planetary lord, and quality.

LordQuality
AmritMoonauspicious
ShubhJupiterauspicious
LabhMercuryauspicious
CharVenusneutral
UdvegSuninauspicious
RogMarsinauspicious
KaalSaturninauspicious

The first slot of each half-day rotates through the cycle by weekday:

Weekday (Mon=0..Sun=6)Day firstNight first
MonAmritChar
TueRogKaal
WedLabhUdveg
ThuShubhAmrit
FriCharRog
SatKaalLabh
SunUdvegShubh

Element transitions across the day

For each of Tithi / Nakshatra / Yoga / Karana we find every boundary the sidereal index crosses between local midnight and the next local midnight. The algorithm samples the index every 30 minutes (the slowest element — Karana — changes ~every 11 hours, so this is generously dense), and any sample-to-sample index change is bisected to ~1-second precision. The output is a list of (name, index, start, end) segments covering the whole local day; the first segment's start is local midnight and the last segment's end is the next local midnight.

Named yoga detection

A YogaFlag carries a name, a sense (auspicious / inauspicious), and a window. We detect:

  • Sarvartha Siddhi (auspicious) — fires when the Vara × Nakshatra combination matches the BPHS table, e.g. Sun + Hasta, Mon + Rohini, Wed + Krittika. Window = the matching nakshatra's segment.
  • Amrita Siddhi (auspicious) — the strongest single Vara × Nakshatra combination per day (Sun + Hasta, Mon + Mrigashira, Tue + Ashwini, Wed + Anuradha, Thu + Pushya, Fri + Revati, Sat + Rohini).
  • Vyatipata (inauspicious) — fires whenever the Panchang Yoga is Vyatipata (index 16). Window = that yoga's segment.
  • Vaidhriti (inauspicious) — fires whenever the Panchang Yoga is Vaidhriti (index 26).
  • Bhadra (inauspicious) — fires whenever the Karana is Vishti. Window = that karana's segment. Bhadra is one of the most consequential inauspicious flags for muhūrta — weddings, journeys, and major undertakings are traditionally postponed.
  • Panchaka (inauspicious) — fires when the Moon transits Dhanishta through Revati (nakshatra indices 22–26). Whole-nakshatra approximation; the strict tradition starts mid-Dhanishta.
  • Rikta Tithi (inauspicious) — fires on Chaturthi, Navami, and Chaturdashi of either fortnight (tithi indices 3, 8, 13, 18, 23, 28).

When multiple flags fire, the deterministic guidance composer picks the highest-priority auspicious flag (Amrita Siddhi > Sarvartha Siddhi) and the highest-priority inauspicious flag (Vyatipata > Bhadra > Vaidhriti > Panchaka > Rikta Tithi) and joins their Ashoka-voice template lines for the home-slide guidance string.

Personalisation — Taarabala, Chandrabala, and the timing bar

The /panchang page opens with two layers computed on-the-fly per request (no cache row, no migration) in services/jyotish/personal_panchang.py.

The personal block weighs today's sky against the signed-in user's natal Moon. The natal Moon comes from the user's primary chart; today's transiting Moon sign is sampled at sunrise. The two classical measures — Taarabala (nine-fold cycle counted from the birth Nakshatra) and Chandrabala (house of the transit Moon counted from the natal Moon sign) — are computed by the canonical functions in compatibility.py (single source of truth); personal_panchang.py owns only the Panchang-page presentation: an evocative gloss per tara, a life-area phrase per Chandrabala house, a three-way display sense (favourable / unfavourable / neutral), a deterministic no-Claude paragraph in Ashoka's voice, and chips. With no birth time on file the block silently degrades to {available: false} and the page falls back to the shared summary prose.

The timing bar is a four-band timeline (favourable / neutral / caution / avoid) tiling the local civil day. It has two modes. With no birth time on file it is impersonal: score_timeline (in personal_panchang.py) collects the day's Choghadiya segments and the Rahu Kaal / Yamaganda / Gulika windows plus Abhijit, breaks the day at every segment boundary, and paints each slice by the band at its midpoint under a fixed precedence: Abhijit (favourable, dispels an overlapping inauspicious window) > Rahu Kaal / Yamaganda / Gulika (avoid) > Choghadiya quality (auspicious→favourable, inauspicious→caution, neutral→neutral) > rest (neutral, outside sunrise→sunset). Adjacent slices with the same band and label merge.

When a birth time is on file, the same bar is re-scored per user by services/jyotish/personal_muhurta.py (score_personal_timeline), and /panchang flags it timing_bar.personal = true. The day is divided into 24 unequal planetary horas — sunrise→sunset and sunset→next-sunrise each split into twelve, classical and consistent with the Choghadiya day/night reckoning — the first hora of the day ruled by the weekday lord and advancing through the Chaldean sequence (_HORA_CYCLE in strength.py, the single source of truth). Each hour is scored under a tiered rule chosen for explainability:

  • Tier 1 — hard overrides. Rahu Kaal / Yamaganda / Gulika and Vishti (Bhadra) karana force avoid; Abhijit forces favourable and dispels overlaps. These rule outright, ahead of any personal weight.
  • Tier 2 — Chandrabala base lean. The transit Moon's house from the natal Moon sets the hour's underlying lean (favourable / neutral / caution). The Moon moves ~13°/day, so at most one sign change per day; _moon_sign_spans resolves the crossing by linear interpolation (≤ 2 spans) rather than per-hour ephemeris calls.
  • Tier 3 — Hora, Choghadiya, Taarabala tiebreak. The hora lord is weighted against the user's ascendant via functional_nature(lagna) (dignities.py): a functional benefic or yogakaraka nudges +1, a functional malefic −1, else 0. The shared Choghadiya quality and Taarabala add their own ±1. Their sum shifts the base lean up or down a band; the deciding minor factor is named in the per-segment reason.

Scoring covers sunrise→midnight (day + night horas); the pre-dawn stretch (midnight→sunrise) stays neutral "Rest", matching the impersonal bar. The block degrades silently to {available: false} (impersonal fallback) when there is no profile, no birth time, or unusable coordinates — never a guess. Adjacent slices with the same band, label and reason merge.

The daily verdict (#154) rolls today's bar up to one headline atop the personal block. pm.roll_up_day(timeline) keys the verdict on how much of the day scores personally favourable (favourable / steady / mixed / caution) — the pre-dawn "Rest" contributes no favourable minutes, so it can't dominate, and the daily hard-avoid windows aren't penalised. Because it rolls up the same segments the bar draws, the headline can't contradict the band beneath it. pp.compose_day_verdict adds a deterministic, no-Claude reason naming the dominant drivers (the Moon's house lean, the taara, and the best window's time of day). It attaches to the personal block and silent-degrades with it (no birth time → no verdict).

The multi-day outlook (#155 — the "Days ahead" tab) generalises this to a range. For each of the next N days (7 by default, up to 30) it computes that day's astronomy directly via compute_full_panchang (no cache row, no Claude prose), scores it with the same score_personal_timeline, and rolls the day up to one verdict with roll_up_day (personal_muhurta.py): favourable / steady / mixed / caution, keyed on how much of the day scores personally favourable, with the longest favourable segment surfaced as the day's best window. The natal chart is resolved once; the per-day ephemeris helpers acquire SWE_LOCK internally and are not re-wrapped (the #151 non-reentrant-lock rule). Served by GET /panchang/outlook — verified-gated, computed on the fly, never cached — and each day links through to its full personal Panchang. roll_up_day is the single per-day rule shared by the daily verdict (#154) and this range outlook.

Accuracy — audit against Drik Panchang (2026)

The panchang engine was cross-checked against Drik Panchang (same Lahiri ayanamsa) over a 10-cell matrix chosen to hit 2026 edge cases — Adhik Jyeshtha, Amavasya, Purnima, a Kshaya tithi boundary, and a Sankranti month change — across Ahmedabad, London, New York, Dubai and Auckland. Our side was computed by running compute_full_panchang on the live backend.

Verified correct (to ~1 minute): sunrise/sunset; all five limbs at sunrise and all four limb transition times (Kimstughna-at-index-0 karana fix from #112 holds); Rahu Kaal; Yamaganda; Gulika Kaal; Abhijit (8th daytime muhūrta); Choghadiya (day and night); and Adhik Jyeshtha detection with correct Amanta month boundaries.

The three defects the 2026 audit surfaced — solar-noon vs sunrise limb sampling (HIGH), the Yamaganda off-by-one (MEDIUM), and the Abhijit window width (LOW) — were all corrected in #150.

Intentional, not bugs: we surface Vikram Samvat under Kartak-adi (Gujarati) reckoning (2082 in mid-2026) where Drik shows Chaitra-adi 2083 with a Jovian year name — a documented audience choice (see Calculation conventions). Moonrise/moonset agree with Drik to ~4–6 min (a horizon/limb/parallax convention difference; sun events are <1 min).


Sky Guide (transits + visibility)

The Sky Guide uses swe.azalt() to convert each visible planet's geocentric longitude into local altitude/azimuth from the user's current GPS location and clock time — not the natal location. We then layer the natal connection onto the live sky:

  • Lagna lord and current Mahadasha lord are tagged when visible.
  • Each visible planet is mapped to the natal house it currently transits.
  • Each visible planet's current nakshatra is shown.
  • A planet within a few degrees of culmination (highest altitude in the sky) is flagged — classical sources treat culmination as a moment of peak strength, similar in spirit to digbala.

Today's Reading (gochar engine)

/your-chart/today reads today's sky against the user's natal chart. Auth-gated: logged in and verified, mirroring /panchang. Each day's reading is persisted so past days can be revisited from the history drawer or by date at /your-chart/today/<date>.

Transit positions. We re-use the natal pipeline, replacing the birth moment with datetime.now(UTC) and skipping the ascendant calculation (transits are graha-relative, not place-relative). Same Lahiri ayanamsa, same FLG_SPEED flag pattern, same Mean Node + Ketu = Rahu + 180° convention. Reference: src/implementations/ashoka/backend/app/services/jyotish/gochar.py.

Natal-house overlay. Each transit graha is mapped into a whole-sign natal house: (transit_sign_idx − natal_lagna_sign_idx) mod 12 + 1. This matches the natal house system used throughout Your Chart (e.g. the Kundli diagram on /your-chart/chart-shape).

Per-graha signature flags. For each transit we record:

  • dignityexalted, debilitated, own, or none (re-uses the same exaltation tables as the natal dignities module).
  • retrograde — from xx[3] < 0 of the speed-only calc_ut.
  • aspects_lagna — true when the transit graha sits in the natal lagna sign (1st) or its opposition (7th). v1 only flags these two; full Vedic aspects (3, 7, 10 for Mangal; 5, 7, 9 for Guru; 3, 7, 10 for Shani; etc.) are intentionally not surfaced yet.
  • sade_sati_phase — set only on Shani, via the existing sade_sati() helper in compatibility.py.
  • in_natal_sign — return-to-natal-sign signature, requires the natal graha → sign map to be passed in.
  • natal_lord — true when the transit graha is the lagna lord.

"Three loudest" — v1 heuristic. We always surface Shani, Guru, Rahu — the three slowest movers, with the longest narrative arcs. This is deliberately fixed for v1; a configurable scoring rule (factoring in dignity, return-to-natal, lagna-lord transit, Sade Sati phase, aspects to the natal Moon) is tracked separately.

Today's Gita verse. Re-uses select_verse(lagna_sign, moon_sign) but passes the transiting Moon sign, so the verse rotates daily while staying themed to the user's lagna.

Caching. Each daily reading is persisted, keyed per user, profile and date. The "Go deeper" Ashoka passages on the same page are cached per focus area (career, relationships, spiritual, health, wealth, family). The "today" date is computed in the profile's timezone, so the reading rotates at the user's local midnight.

AI generation. The hero one-liner, the three insights, the Ashoka commentary on the verse, and each "Go deeper" passage are generated by claude-sonnet-4-6 from the structured gochar payload + a compact natal summary. Returned as JSON, parsed, persisted.


Calendar / Almanac

The Vedic almanac at /calendar is a month-by-month view of computed events plus their claude-sonnet-4-6-generated literary content.

What each event carries. One entry per emitted (event, day) pair; multiple events on the same day stand as separate entries. Each records its date, an event_type (festival / ekadashi / purnima / amavasya / pradosh / sankranti), a name and any popular regional aliases (Pongal / Lohri for Makar Sankranti, etc.), the tithi and nakshatra, the deterministic facts that drove its prose (including tradition and region tags), and the generated prose itself.

Spans. A festival-level entity for multi-day arcs: one per festival, with member events resolving to specific years by their date. Eight spans are currently seeded: sharad_navaratri (9 days), chaitra_navaratri (9), pitru_paksha (15), ganesh_utsav (11), diwali_week (5), pongal (4), onam (10), paryushan (8). Each carries a canonical_length (so a sparsely tagged festival's day-popup reads "Ganesh Utsav · 11-day festival" rather than the misleading "Day 1 of 2") and an AI-generated festival-level overview shown in the day-popup's "About the festival" tab.

Scannerapp/services/calendar/event_scanner.py. Walks every day in [start, end], samples noon UTC, computes the standard astronomical context (tithi, nakshatra, sun sign, Hindu lunar month under Amanta naming, Adhik Maas detection, Vikram Samvat year, ritu, Gujarati aliases). Then iterates festival rules from festivals.yaml (~138 entries) and dispatches by rule_type:

  • tithi_match (default) — fires on (hindu_month, paksha, tithi). Covers most named festivals (Diwali, Janmashtami, all Ekadashis, Pitru Paksha shraddhas, Navadurga days, etc.). Optional observance_moment selects the day-selection anchor: ekadashi_smarta, nishita, nishita_janmashtami, moonrise, arunodaya, pradosh, pradosh_amavasya, madhyahna_purnima, madhyahna_vyapini, aparahna, aparahna_conjunctive, follows, navami_merge (see documents/domains/jyotish/calendar_observance_conventions.md for derivations). Default is sunrise (udaya-tithi).

  • weekday_in_month — fires on every (or a specific) weekday inside a Hindu lunar month. Used by Shravan Somvar and Kartik Somvar (every Monday of those months becomes a Shiva vrat). occurrence: all is the v1 shipping value; Nth-occurrence variants documented as the extension point.

  • solar_anchor — fires on a sun-sign-crossing date with an optional offset_days. The scanner precomputes the year's sankranti dates once into a lookup table; each rule check is O(1). Covers Vishu / Vaisakhi / Bohag Bihu / Pohela Boishakh (all anchor to Mesha Sankranti) and the four Pongal days (Bhogi −1, Thai Pongal 0, Mattu +1, Kaanum +2 from Makara Sankranti).

  • nakshatra_in_solar_month — fires when the moon is in a target nakshatra AND the sun is in a target sidereal sign (i.e. inside a named Tamil / Malayalam solar month). Covers Onam's ten days (Atham → Thiruvonam inside Chingam, sun in Simha) and Karthigai Deepam (moon in Krittika, sun in Vrishchika). A per-solar-period "fired" tracker suppresses moon re-visits to the same nakshatra in the same solar window — Kerala convention is to celebrate the first occurrence.

Sankranti naming. The twelve sun-sign-crossing emits use canonical Sanskrit short-form names (Makar / Mesh / Karka / etc., from a _SANKRANTI_NAMES map) and the three culturally heaviest carry alternate_names: Makar Sankranti → Pongal, Lohri, Uttarayana; Mesh Sankranti → Vishu, Vaisakhi; Karka Sankranti → Dakshinayana.

Tradition + region tagging. Every rule carries tradition (hindu / sikh / jain / buddhist, default [hindu], multi-valued allowed e.g. Vaisakhi = [hindu, sikh]) and region (all-india / tamil / malayalam / punjabi / bengali / marathi / gujarati / assamese / odia / kannada / telugu, default [all-india]). The calendar API surfaces both and lets the user filter the month by them.

Per-tradition principle for shared dates. When two traditions observe distinct festivals on the same calendar date, each emits as its own event row rather than collapsing into a composite. Diwali night (Ashwin Krishna 15) produces six rows: Naraka Chaturdashi, Diwali · Lakshmi Puja, Ashwin Amavasya (the recurring lunar marker), Bandi Chhor Divas (Sikh), Mahavir Nirvana (Jain), Kali Puja (Bengali). Same principle for Ram Navami + Swaminarayan Jayanti on Chaitra Shukla 9, Krishna Janmashtami + Hari Jayanti on Shravana Krishna 8, etc.

Generator. Deterministic facts are handed to claude-sonnet-4-6 with few-shot examples covering the tonal range — both for per-event prose and for the eight spans' festival-level overviews; output schema is validated, bad output fails loudly.

Personalisation. A pure function (event_date, event_type, profile) → footer | None. For Purnima/Amavasya it samples transit Chandra; for Sankranti, transit Surya; for events without a clean rule it returns nothing. The house is (transit_sidereal_sign_index − natal_lagna_sign_index) mod 12 + 1, and the wording is templated from a fixed house-meaning table — no Claude call.

API. The calendar API serves public month metadata, a verified-only full payload (with a per-user personalisation footer per event), the next upcoming event, the multi-day span list (one result per span and year, surviving a filter only if at least one member event passes), and fuzzy search across event names and aliases (span hits first, so "Navaratri" surfaces the span card before its nine day rows). Admin tools regenerate prose per event, per month, or per span, and reseed a date range.

Freshness. A nightly job detects the forward edge of seeded data and scans the next missing month (capped at one month per run for predictable API spend), generating prose and skipping events that already exist. A health alert fires if a month's emission count drops sharply versus the same month a year earlier. Reseeds can backfill new structured fields onto existing rows without re-running Claude.

Verification. test_calendar_ground_truth.py cross-checks ~30 core Hindu festivals against DrikPanchang dates for 2025-2027. test_festivals_emit.py (universal) asserts every yaml slug produces at least one event in the same 3-year window. test_sankranti_renames.py checks the Sankranti map applies correctly. A periodic Drik-diff job + per-event YoY cron check + ground-truth backfill for the post-2026 content additions are tracked in #147.

Public teaching pages — SEO (/festivals, /learn)

Two open, prerendered page sets draw on this calendar data: a festival library (/festivals/:slug) and a Jyotish/Panchang glossary (/learn/:slug, covering core concepts, the nine grahas, and the twenty-seven nakshatras). Festival pages carry multi-year dates and schema.org Event markup; glossary pages carry DefinedTerm + Article. Festival dates are presently hand-maintained from the calculated calendar (a build-time refresh from the calendar API is a follow-up).

These public routes are statically prerendered at build time: the client SPA is rendered to real HTML per route with React's renderToString, with per-route <title>/description/canonical/Open Graph and JSON-LD baked into <head>, plus a generated sitemap.xml and robots.txt. This is the indexability foundation (the SPA otherwise served a near-empty shell to crawlers); the gated, personalised chart/reading is excluded.


Ishta Devata

Derivation per the classical method:

  1. Atmakaraka. Among the seven classical planets, the one with the highest degree within its sign (floor of the within-sign offset).
  2. Karakamsha. The sign occupied by the Atmakaraka in the Navamsa (D9).
  3. 12th from Karakamsha. Counted in the rāśi chart.
  4. Lord of that 12th sign → its presiding deity per the BPHS-aligned planet → deity mapping.

Where multiple deities are associated with a single planet we present the primary deity (e.g. Vishnu for Sun) and note variants.


What we know we do not yet do

This list is the engineering view of the limitations summarised on Our Approach.

  • User-selectable ayanamsa (Raman, KP, True Chitrapaksha) is not exposed.
  • True node vs. mean node is not user-selectable.
  • Prasna, Tajika, and Jaimini-only systems are not implemented.
  • Yoga library is non-exhaustive.
  • Per-Kakshya Ashtakavarga, Yogini and Chara Dasha display, and divisional charts beyond D9 / D60 are computed but not yet surfaced in the UI.
  • Calendar date verification against external panchang sources (DrikPanchang and regional almanacs) is partial — the ~30-festival ground-truth set doesn't yet cover all of the post-2026 content additions (Tier 1 sect events, the Onam ten-day arc, Pongal sub-days). Tracked in #147.
  • Tier 2 sect content (Lingayat, Sri Vaishnava, Pushtimarg, Sant traditions, Bengali Durga Puja arc) and Gregorian-date festivals (Mahant Swami Maharaj Jayanti) are parked in #148, awaiting prioritisation.
  • The week-view grid binding strip doesn't graphically connect across week-row boundaries — a multi-day span that crosses a Sunday/Monday line reads as two visually disconnected segments. Accepted v1 limitation.

Cross-references

← Return to Our Approach

withashoka.com

A small offering
What did Ashoka get right? What did he miss?

Anything you share helps Ashoka speak more clearly to the next friend who arrives.

Sent straight to Ashoka
✦ A preview, friend — what you read or save here may shift.