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
| Layer | Choice |
|---|---|
| Ephemeris | Swiss Ephemeris via pyswisseph 2.10.3.2 |
| Ephemeris files | semo_18.se1, sepl_18.se1, seas_18.se1 (cover 1800–2400 CE) |
| Ayanamsa | Lahiri / Chitrapaksha (SIDM_LAHIRI), applied manually |
| House system | Whole-Sign (Sampoorna Bhava), with Bhava Chalit (equal-house) and Placidus also computed |
| Node convention | Mean node (swe.MEAN_NODE); Ketu = Rahu + 180° |
| Time base | Julian 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.
| Graha | Direct | Retro |
|---|---|---|
| Chandra | 12 | 12 |
| Mangal | 17 | 17 |
| Budha | 14 | 12 |
| Guru | 11 | 11 |
| Shukra | 10 | 8 |
| Shani | 15 | 15 |
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:
- 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.
- 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.
- 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.
| Chart | Division | Method |
|---|---|---|
| D1 Rāśi | 1× | λ directly |
| D7 Saptamśa | 7× | Equal 7-fold split per sign, parity of source sign decides starting sign |
| D9 Navāmśa | 9× | Equal 9-fold split, starts from sign of same element (movable/fixed/dual) |
| D10 Daśāmśa | 10× | Equal 10-fold split, odd signs from self, even from 9th |
| D12 Dvādaśāmśa | 12× | Equal 12-fold split, starts from the sign itself |
| D16 Ṣoḍaśāmśa | 16× | Equal 16-fold split, starts from Aries / Leo / Sagittarius depending on movable / fixed / dual |
| D20 Vimśāmśa | 20× | Equal 20-fold split, similar starting rule |
| D30 Triṃśāmśa | unequal | BPHS unequal division — 5°/5°/8°/7°/5° per sign with fixed lordship per slot |
| D60 Ṣaṣṭyāmśa | 60× | 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:
- The lord of the debilitation sign is in a kendra from the Lagna or Moon.
- The planet that would be exalted in the debilitation sign is in a kendra from the Lagna or Moon.
- The dispositor of the debilitated planet is exalted.
- 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.)
| Element | Computation |
|---|---|
| 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 |
| Nakshatra | Moon_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 Kaal | Yamaganda | Gulika |
|---|---|---|---|
| Mon | 2 | 4 | 6 |
| Tue | 7 | 3 | 5 |
| Wed | 5 | 2 | 4 |
| Thu | 6 | 1 | 3 |
| Fri | 4 | 7 | 2 |
| Sat | 3 | 6 | 1 |
| Sun | 8 | 5 | 7 |
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.
| Lord | Quality | |
|---|---|---|
| Amrit | Moon | auspicious |
| Shubh | Jupiter | auspicious |
| Labh | Mercury | auspicious |
| Char | Venus | neutral |
| Udveg | Sun | inauspicious |
| Rog | Mars | inauspicious |
| Kaal | Saturn | inauspicious |
The first slot of each half-day rotates through the cycle by weekday:
| Weekday (Mon=0..Sun=6) | Day first | Night first |
|---|---|---|
| Mon | Amrit | Char |
| Tue | Rog | Kaal |
| Wed | Labh | Udveg |
| Thu | Shubh | Amrit |
| Fri | Char | Rog |
| Sat | Kaal | Labh |
| Sun | Udveg | Shubh |
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_spansresolves 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-segmentreason.
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:
dignity—exalted,debilitated,own, ornone(re-uses the same exaltation tables as the nataldignitiesmodule).retrograde— fromxx[3] < 0of the speed-onlycalc_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 existingsade_sati()helper incompatibility.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.
Scanner — app/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.). Optionalobservance_momentselects the day-selection anchor:ekadashi_smarta,nishita,nishita_janmashtami,moonrise,arunodaya,pradosh,pradosh_amavasya,madhyahna_purnima,madhyahna_vyapini,aparahna,aparahna_conjunctive,follows,navami_merge(seedocuments/domains/jyotish/calendar_observance_conventions.mdfor derivations). Default issunrise(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: allis the v1 shipping value; Nth-occurrence variants documented as the extension point. -
solar_anchor— fires on a sun-sign-crossing date with an optionaloffset_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:
- Atmakaraka. Among the seven classical planets, the one with the
highest degree within its sign (
floorof the within-sign offset). - Karakamsha. The sign occupied by the Atmakaraka in the Navamsa (D9).
- 12th from Karakamsha. Counted in the rāśi chart.
- 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
- Our Approach to Jyotish — the knowledgeable-student companion to this page.
withashoka.com