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 the UT Julian Day jd (Meeus, Astronomical Algorithms, eq. 12.4):

d            = jd - 2451545.0
T            = d / 36525.0
gmst_degrees = (280.46061837 + 360.98564736629 · d
                + 0.000387933 · T² - T³ / 38710000) mod 360

The 360.9856…° a day term carries the Earth's turn through the day, so the rising sign moves through all twelve signs in a day. Until October 2026 the engine used the 0h-UT form of this expression with the full Julian day plus a fixed 180°, which left out the hour of the day: every timed chart's Lagna was wrong except at 12:00 UT. Charts with no birth time were unaffected.

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

ARMC = (gmst_degrees + lng) mod 360

Longitude is an angle and is added as it is. Drik Panchang's Lagna differs from ours by about lng × 0.0027379 (some 14′ for Mumbai, 20′ for Sydney, almost nothing for London), as if longitude were scaled by the ratio of the sidereal to the solar day. We checked against the sky itself — the ecliptic point on the eastern horizon at the moment of birth — and our value agrees with it to a fraction of an arcminute, so we keep the astronomical one. For a birth within about 0.4° of a sign boundary, far from Greenwich, the two can show different Lagna signs (and more often different Navamsa or D60 signs).

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: _armc and calculate_chart in src/implementations/ashoka/backend/app/services/jyotish/chart.py.

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.

With no birth time there is no Lagna: the first whole-sign house is the Sun's sign at local noon (Surya Lagna). See A chart with no birth time.


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āśi1×λ directly
D7 Saptamśa7×Equal 7-fold split per sign, parity of source sign decides starting sign
D9 Navāmśa9×Equal 9-fold split, starts from the sign of the same element: fire → Aries, earth → Capricorn, air → Libra, water → Cancer
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

Correction (September 2026). Until then our Navamsa counted odd signs from Aries and even signs from Capricorn. That is right for fire and earth signs and wrong for air and water signs. It is fixed, and the readings that use the Navamsa (vargottama, Karakamsha, Ishta Devata, the D9 lines in your story) regenerate.

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. With no birth time the D60 chapter is not shown.

The readings read the D10, D7 and D12 by planet placement only (classical.topic_chart_notes): a planet in its own or exalted sign, at its weakest, or in the same sign as in the D1. Their own rising signs are never read: they move every few minutes of birth time.


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.


Hub Highlights — natal detection & scoring

The personal hub (/your-chart) curates a ranked handful of features rather than dumping the whole chart. A library of deterministic detectors each emits a candidate carrying a significance score; the top few are kept and handed to the reading prompt — the narration never decides what is notable. Reference: app/services/jyotish/.../api/v1/hub.py (_CONDITIONAL_BUILDERS, NATAL_HIGHLIGHT_WEIGHTS, _select_conditional_highlights).

Detectors (base weights in NATAL_HIGHLIGHT_WEIGHTS): dignity (exalted / moolatrikona / own / debilitated), notable yoga, functional malefic, stellium (3 / 4 / 5+), conjunction, yogakaraka (+ strong-placement bonus), vargottama (lagna / graha), the Rahu–Ketu axis (conjunct / angular), retrograde, combust. The two always-on leads — rising sign and Moon — sit above the scored set and are not ranked. A vargottama Moon is folded into the Moon lead rather than emitted as its own tile (two passages about the same Moon read as a re-run). Each candidate's chart-language line names the houses the graha rules and the life areas they govern; the Neecha Bhanga line names each cancelled graha and the classical condition that cancels it. These facts feed two surfaces: the Jyotish tab (a static teaching paragraph per feature + the fact line, no model call) and the story — one continuous narrative written from the facts as evidence, framed by the person's age band, naming the current Mahadasha and the Ishta Devata. The story also receives the present chapter read in this chart (the Dasha lords' house, dignity and rulerships) and, per life area, the ruling graha's dignity and house plus the natural significator, Dhana yoga, Amatyakaraka and Mangal Dosha, so money, work, children and the chapter differ between charts. The prose is cached per chart + Mahadasha lord + age band and regenerates once when either rolls. The eight chapter sub-pages follow the same split: a continuous "Your reading" in voice v2 and a "Jyotish" tab of generated teaching passages (learning register, Sanskrit-first), produced in the same call as {reading, teaching{…}}.

Houses & Planets (chapter 2) is the exception. Its "Your reading" tab is a walk-through of the twelve houses beside the Kundli: an overview card (a paragraph on where the lagna lord sits, then two signposts into the houses with the most to say, chosen by fixed rules: a stellium, an exalted or debilitated planet, a lord in its own house, a retrograde or combust planet, the Yogakaraka), then one card per house, stepped by arrows, swipe, the keyboard, or a tap on a house in the chart. The top of each card (what the house is for) is fixed copy, the same for every reader. Below it, what this chart shows in the house is written by Claude from the house's sign, its occupants (with dignity, retrograde and combust flags), where its lord sits, the lagna's functional benefics and malefics, and the house's named features; an empty house is read through its lord. Named features are shown as badges on the card and fed to the prompt: stellium, conjunction, Yogakaraka here, Gajakesari, the five Pancha Mahapurusha yogas (Ruchaka, Bhadra, Hamsa, Malavya, Shasha), the ruler in its own house, and Neecha Bhanga on a debilitated occupant. The Rahu and Ketu cards name the opposite node's house. The overview and twelve readings are one generation of their own (POST /api/v1/hub/houses, claude-sonnet-4-6), cached per chart + language and persisted with the natal reading. Anonymous visitors read the overview and the 1st house; the rest needs an account. The Kundli is drawn as the standard North Indian diamond: the 1st house is the top diamond and the houses run anticlockwise.

Placement intensity (karmic load)

A cross-cutting signal — app/services/jyotish/intensity.py, placement_intensity(graha, sign, degree, house, nakshatra) — scores how karmically loaded a placement is and feeds both the ranking and the narrative:

SignalTestBonus
Gandantawithin 0.8° of a water→fire sign boundary (end of Karka / Vrishchika / Meena, or start of Simha / Dhanu / Mesha)+12
Knotty nakshatraKetu-ruled {Ashwini, Magha, Mula} ∪ gandanta-junction {Ashlesha, Jyeshtha, Revati} (Rahu-ruled nakshatras, incl. Ardra, excluded by design)+6
Near-exact dignitywithin 2° of the exact exaltation / debilitation degree (nodes have no exaltation degree, so this never fires for Rahu/Ketu)+8
Moksha dusthana8th or 12th house (the 4th is a kendra of inner peace — excluded)+6

Capped at +20 per graha. The bonus lifts the owning highlight's rank; the human-readable reasons plus a flavour (knot / strain / depth / reinforcement) are appended to the deterministic chart_language so the narration names the load with the right verb. The Moon highlight (always shown) takes the narrative enrichment only.

Node gate. _detect_nodes also admits a node at a gandanta from any house. Moksha houses (8/12) are deliberately not a standalone gate: because the nodes are always opposite, every node pair already touches {kendra ∪ trikona ∪ 8/12}, so admitting 8/12 alone would fire for every chart. A moksha-house node still earns the +6 bonus when it surfaces.

The karmic-weight meta-line

A chart is flagged as carrying "unusual karmic weight" — one sentence opening the newcomer Quick read — when either (a) two or more placements score ≥ 14, or (b) one placement is extreme (a gandanta, or near-exact dignity ≤ 1°) on a soul- or karma-bearing point: the Moon, the Lagna, the Atmakaraka, or a node. Reference: _chart_intensity_meta in hub.py. Calibrated over the real charts + a 2,000-chart synthetic sample to fire ~11% of the time (a ≥ 10 floor fired ~18% — too generous); the per-graha weights leave the top-4 membership displacement at ~10%. Full spec and calibration: documents/domains/jyotish/placement_intensity_spec.md.

With a spine (below), the reading opens with the spine's printed opener: the story opens on the earliest years instead, and Quick read's first paragraph covers the areas the story spends most on beyond the opener. The karmic-weight note keeps its one quick-read sentence.

The spine — what a chart turns on

app/services/spine.py. pick_spine scores each house: lagna lord sits there 3, Atmakaraka 3, Moon 2, Mahadasha lord 2, yogakaraka 2 (and 1 for each house it rules); each main life area's ruler sitting there 1; a sign exchange 2 to both houses; three or more planets 2; each house the lagna lord, Atmakaraka, Moon, Mahadasha lord or yogakaraka aspects 1. Sarvashtakavarga never picks the house: a lead house that is weak or one of the two least supported makes the spine "hard-won". A runner-up within a quarter of the lead makes a two-strand spine. Evidence added without scoring: the lead counted from the Moon, and where every chain of sign rulers ends. Facts against it: the two least-supported houses, debilitated rulers (with Neecha Bhanga, and a note when the house is still well supported), a combust soul planet or lagna lord, planetary war, and a functional-malefic Mahadasha lord. No birth time, no spine.

The opener. app/services/spine_writer.py: one short claude-sonnet-4-6 call writes the reading's opener from the pick, 50–130 words in plain words with no imagery. It is given the life area, whether it comes hard, the second strand, the evidence (never to be explained), points drawn from the strongest evidence (the Atmakaraka or Mahadasha lord in the house, the yogakaraka in or ruling it) and the god of the rising degree's nakshatra, as who the person is at the root. The facts against are not given to it: they stay with the story and chapters. When two or more of the seven planets sit in the lead house, the engine writes the weight sentence itself (spine_writer.weight_sentence: "Three of your seven planets gather there, among them the one that stands for you and your soul planet, drawing four other parts of your life in with them", counting the lagna lord, the Atmakaraka and the other houses those planets rule); the writer is told to give it word for word after its first sentence and never to enlarge it, and with_weight puts it there if it did not. It is part of the cache key. Code checks send it back once when it does not name the area, does not say a hard spine is hard, names a planet, sign or house, uses a dash, a reframe, the stock opening ("your life is built around") or the engine's own words, or misses the length; it is then left out. It is stored in chart_spines (migration 0077), keyed by a hash of the pick, the rising god and the voice ("you" in the app, "they" in a booklet), and shared by the story, every chapter, the house walk and every booklet section; its first sentence is the one-line spine Today uses. The app shows it at the head of the Quick read tab on the reading page, above the two Quick read paragraphs (opener in the /hub response); the booklet prints it on the chart-at-a-glance page in place of the hook. The story is told it has been read: it opens on who the person is day to day (temperament and manner, from the rising sign and the Moon), is not given the rising nakshatra, and is sent back once when it copies a run of six or more of the opener's words (opener_echoes), as is Quick read.

The family and the house labels

The story has one paragraph on the family a person was born into, read only from its own block of facts (_story_life_facts, THE FAMILY THEY WERE BORN INTO): the father from the 9th house and the Sun, the mother from the 4th and the Moon, and the household from the 2nd (Parashara). A parent the facts say nothing particular about is left out. Brothers and sisters are handled differently, because a chart cannot say whether a person has any: the facts carry a line only when a planet sits in the 3rd ("those they grew up beside") or the 11th ("those who went ahead of them"), or Mars is notably strong or strained; a ruler's placement alone gives none. The story then says one sentence at most, about this person's side of the bond, never that a sibling exists, is younger or elder, or anything of a sibling's own life (sibling_spans catches those and the repair mends them; with no line, any mention). The 3rd and 11th house cards say how the house is read ("this house is also read for younger brothers and sisters; for you it shows...") only when a planet sits there; an empty card's sibling sentence is removed. The story's other labels name no family member (_HOUSE_THEME_VARIANTS, spine.AREA), and a fact against that touches one of those houses says the relation belongs in the family paragraph (spine.RELATION). Work is treated like siblings: the story reads the 10th as what a person does in the world and how they are regarded (_STORY_WORK; the facts are headed so, and spine.AREA[10] is "their work in the world and how they are regarded"), and never names a job, a field or a workplace, the words career, profession or job, or a source of income (voice_checks.occupation_spans, mended in place as an "occupation claim"; the spine's opener is checked too). The 10th house card and the teaching text keep the word career. The same principle covers the whole story (_STORY_KNOWN): the chart says how each part of life goes for a person, never that an event took place or that a spouse, a child, a journey or a fortune exists; past chapters are named by their theme only, and both the "shaping" and "reflection" age frames and the booklet's elder voice speak in the present tense. Chart terms are mended in place as a "chart term" (hub.chart_term_spans): planet names other than the Mahadasha planet (the deity paragraph may name its planet), signs other than the rising and Moon signs, sign adjectives, house phrases and mechanics words. The story runs 900-1250 words and is sent back over 1,300, about what two booklet pages hold. In the booklet PDF a page of reading text that still runs past its area after the layout pass (booklet/render._FIT) hands its last paragraphs to a new page after it, headed "<section>, continued" ("ચાલુ" in Gujarati), and that page does the same until everything fits; nothing is cut and the type is never shrunk, so a booklet runs a page or two longer than its plan. The Moon and the Sun chapter reads the Moon as the mother beside the Sun as the father; each house card reads its family member from the house, its ruler and its occupants.

Labels have one source, app/services/jyotish/house_labels.py (the app's chartShape.walk.houses must match; a test checks): the classical Sanskrit name (Sukha, Dharma...), what that name means (Sukha: ease and contentment), shown beside it on the cards in place of the Western keyword, and Ashoka's English card title, which names the family member a card details ("Home, the mother and inner ease"). The names are classical; the meanings follow the names' standard sense; the titles are ours.

Classical checks and the chapters of the life

app/services/jyotish/classical.py, rendered by _classical_facts in hub.py: Parashari full aspects per house, Arudha Lagna and Upapada (10th from there when the count lands on the house or its 7th), topic-chart planet notes, the ruler chain's end, planetary war, and a rising degree within 1.5° of a sign edge. They feed the story, the rising-sign chapter (Arudha, edge) and the house walk (at most one check per card). _chapters_lines gives the story and the Dashas chapter every Mahadasha from birth to the next one; past chapters are read by what they asked, never by events.

Voice checks

app/services/voice_checks.py catches the reframe ("X is not A. It is B.") and the internal word "spine"; the flagged sentences are rewritten in place by one short call (a full rewrite writes new reframes). rising_problems sends the story back once when it names the wrong rising sign or corrects itself; that and the no-birth-time rules are the only reasons the story is written again (about 50 seconds a time). A fact against the spine left out (missing_facts, by the words of its life area) is given its place by one short call that adds a sentence or two to the paragraph it fits best (_cover_facts; kept only if it names the area in the reading's voice). Everything else is mended in place by the same short repair call, each under its own label: a placement named in the family paragraph (family_spans), a copy of the opener in the story or Quick read (opener_echo_spans), a job, field or source of income (occupation_spans), a chart term (chart_term_spans). The repair call's answer is read with voice_checks._read_fixes (the first JSON value in the reply, or numbered pairs as a fallback), and it is asked once more when the answer cannot be read. A story over 1,300 words has its longest paragraphs shortened by one short call (_trim_story), kept only if it fits and loses no fact against the spine.

The reading page loads in two parts. POST /api/v1/hub/frame returns at once everything but the prose (the opener, the highlights, the chapter tiles, the deity pill), so the page shows within seconds with Quick read and Your story marked as still being written; POST /api/v1/hub then fills them in.

These signals also surface on the The Method tab of /your-chart — a static, novice-friendly account of how a reading is derived.


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 keeps only what the whole day of birth settles: Chandrabala when the natal Moon held one sign all day, Taarabala only when it held one nakshatra (see A chart with no birth time). When neither holds, the block degrades to {available: false} with a one-line reason 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) is the day layer of one shared personal day score (app/services/day_score.py, spec documents/architecture/personal_day_score_spec.md); the timing bar above is its hour layer. day_layer(natal, sunrise, tz) splits sunrise → next sunrise into Moon (sign, nakshatra) segments (sky_search.moon_segments) and scores each with pp.score_moon_segment:

  • Chandrabala — houses 1, 3, 6, 7, 10, 11 from the birth Moon +2, else −1; the 8th (Chandrashtama) −4 and its own tier.
  • Taarabala — taara 1–9 → 0, +2, −2, +2, −2, +2, −3, +2, +3.
  • The transit Moon with transit Guru +2; with Mangal or Shani −2; with Rahu or Ketu −1. Over the natal Guru or Shukra +1.
  • With a birth time, the Moon sign's Sarvashtakavarga: ≥ 30 +1, < 25 −1.

pp.score_personal_day takes the segment covering most of the waking day (sunrise to 22:00): score ≥ 2 favourable, ≤ −2 caution, mixed when strong factors pull both ways, otherwise open ("An open day" — formerly "steady"). The special tiers are relative to the reader's own days (day_score.special_rule): aligned ("the stars align") is the best-scoring day of the ritu (two Amanta lunar months, three with an Adhik month) if it scores at least 3, about six a year for every chart; bright ("a bright day") is the best day scoring ≥ 5 in each lunar month of the season that holds no aligned day. Ties go to the earlier day; Chandrashtama days are never either. A fixed threshold (the earlier ≥ 6 rule) gave a third of charts no such day in a year and one in ten twenty-six or more. Chandrashtama is the other named tier, with its window. The weights are our own. pp.compose_day_verdict adds a deterministic, no-Claude reason naming the drivers, and pm.roll_up_day(timeline) still supplies the day's best window from the bar. 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) runs the same day_layer for each of the next N days (7 by default, up to 30), computing each day's astronomy directly via compute_full_panchang (no cache row, no Claude prose); each day carries its verdict, tier and tier window, plus the best window from roll_up_day over that day's score_personal_timeline. The natal chart and its Sarvashtakavarga are 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. The Panchang verdict, this outlook and the Today page all read the same day layer.

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 (the seven centres)

/today (a standalone page; the old /your-chart/today addresses redirect) reads today's sky against the user's natal chart as a seated figure with seven centres (spec documents/domains/jyotish/today_chakra_spec.md). The personal page is auth-gated (logged in and verified, mirroring /panchang); signed-out, /today is a prerendered public landing (TodayLanding: an example figure, the planet–chakra table linking to the /learn chakra pages, an FAQ with FAQPage JSON-LD), and robots.txt disallows the personal /today/<date> pages. Each day's page is persisted so past days can be revisited from the history drawer or by date at /today/<date>.

Transit positions. sky_search.positions(jd) — same Lahiri ayanamsa, same FLG_SPEED pattern, Mean Node + Ketu = Rahu + 180°, under SWE_LOCK. Sign changes, stations and Moon (sign, nakshatra) segments are found by bisection to the minute (sky_search.py).

Which planets appear (today_centres.build_sky):

  • Today — the Moon, always.
  • This season / this chapter — the running Mahadasha and Antardasha lords, with their dates.
  • Pressing now — up to three (pressing_planets): a transit planet that is hard from the birth Moon and debilitated, within a few days of a station, or retrograde; Saturn always counts in Sade Sati or 4th/8th from the birth Moon. Hard: a house outside GOCHAR_GOOD unless relieved by vipareeta vedha or by 5+ of its own Bhinnashtakavarga points in the sign; or a good house blocked by vedha or with 3 or fewer points (VEDHA, Phaladeepika, counted from the Moon; Sun–Saturn and Moon–Mercury exempt; Rahu and Ketu use Saturn's table; points need a birth time). Each carries basis, the reason it is hard. Ranked Saturn phase → debilitated → turning → retrograde. Every planet shown also carries, as qualifiers for the writer only, its house from the rising sign (good or hard there, rising_count) and whether Saturn, Jupiter, Mars or Rahu (its 7th only) looks at the birth Moon or rising sign (looks_at).
  • Lord of the day — the weekday's planet, drawn faintly.

Where each lands — the modern bridge. PLANET_CENTRE: Shani root, Guru sacral, Mangal solar plexus, Shukra heart, Budha throat, Chandra brow, Surya crown, Rahu brow, Ketu crown. The Moon's centre is personal (moon_centre): the centre of a natal planet it is conjunct, else one it opposes, else its sign lord's. This pairing and the rainbow colours are modern and are disclosed on the page ("How this is worked out"). Each centre also carries its natal planet's facts and when the Moon next touches it.

Day and coming up. The day verdict and tier come from the shared day_score.day_layer (see the Panchang's daily verdict); "Coming up" lists the special days and turning days in the next two weeks (at most three special days besides the stars aligning), consecutive Chandrashtama days merged into one window, each by date only. On a special day the Overview opens with a fixed message built from the computed factors (no model call).

Touches from the birth reading. chapter_today: the Moon in the birth sign of the Mahadasha or Antardasha lord. spine_today: the Moon in the sign of the birth reading's spine house (and its second strand's; hub._spine_for, birth time only), or a season, chapter or pressing planet that entered it within ARRIVAL_DAYS (7), so a slow planet's stay does not repeat the line for months. Each adds one instructed sentence to the Overview lead; when both fall on the same day through the Moon (the chapter's planet sits in the spine's sign) they are one sentence, not two. Neither enters the day score. chart_roles gives each planet above the figure its place in the person's own chart (it rules or sits in the spine house, rules the rising sign, is the Atmakaraka or the yogakaraka), carried as in_chart so its card says once why that planet is theirs; birth time only. Saved cards are kept across a rules change only when the same planets are shown and these touches have not appeared or gone.

A long season (_season_for, STANDING_DAYS = 14). A pressing planet whose period (its Sade Sati phase, its stay in the sign, or its retrograde stretch) began more than 14 days ago stops being written afresh each day. Its sky entry carries season: the day count, the changes ahead (the Moon's next stay in its sign and in the opposite sign, its next station, its next change of nakshatra, the end of the period) and why, set only on the days it is felt more: within three days of a station, the Moon in its sign or the opposite sign, or within a day of a change of nakshatra. On a day with no why the writer is told to write no card for it and the page shows a standing line; on a why day the full card returns and opens on the reason. The standing line and the season's own page (generate_season: line, about) are written once per chart and season, refused if they name a date or a day, and reused from memory or from the last week's saved days, so the words do not drift. On a favourable day with a quiet season the Overview gets one clause that the season eases. The day score is untouched. No migration: the words ride on the saved day's sky.

The lunar day (lunar_days, birth_tithi). The page carries lunar: the tithi at local sunrise (index 0-29, name, paksha), and for Purnima and Amavasya the house the Moon is in, counted from the rising sign (the Sun's sign without a birth time), with the house's card title. The birth tithi is the Moon's lead over the Sun at birth in twelfths of a circle, read from the chart's own longitudes; the birth lunar month is hindu_month_for at the birth moment (Amanta). A day whose sunrise tithi equals the birth tithi is birth_tithi; when its lunar month also equals the birth month and is not Adhik it is tithi_birthday, with the years completed. The same days within COMING_DAYS, and each Purnima and Amavasya, join coming_up as their own tiers and can be reminded. The page shows the birth-tithi days as an opening box and all of them in Coming up; the day's own tithi is not shown (it is on the Panchang). All of it is fixed text: no model call, and no factor in the day score. No birth time means no birth tithi (a tithi turns over about once a day). A tithi that never holds at sunrise in a month (kshaya) gives no return that month.

Turning days (jyotish/turnings.py). The day a chapter of years turns: every Mahadasha and Antardasha change (Vimshottari, as dasha.py; left out with no birth time), Saturn's first crossings into the 12th, 1st, 2nd and 3rd signs from the birth Moon (Sade Sati begins, its middle years, its last years, it ends), and the first crossings of Saturn, Jupiter and Rahu into their birth signs (returns, numbered by age), and, with a birth time, the double transit (double_transits): Jupiter and Saturn both acting on the 1st, 7th or 10th house from the lagna, by occupation or full aspect (Jupiter 5/7/9, Saturn 3/7/10), dated from when they begin to the day they part, a gap of up to 200 days (a retrograde step back) counting as one meeting; its key carries the house. A crossing counts only when made in the planet's usual direction and not within 400 days of an earlier crossing into the same sign, so a retrograde loop back over the line counts once. Each shows on Today for 2% of the chapter it opens (3–14 days), centred on the day (half before), and in Coming up within two weeks. POST /today/years lists the chapters running and to come (16 years ahead) for the Dasha page. The so-what (what ends, what begins, what changes, what helps) is written by claude-sonnet-4-6 from the computed facts (house, rulership, dignity, dates, any overlap with Sade Sati) on first need (POST /today/turning) and kept in user_highlights_readings under the language plus -t.

How do you feel? Eight feelings, each mapped by fixed rules (today_centres._RULES) to the computed factors true today (Chandrashtama, Saturn pressing, the Moon with Rahu, and so on); the card names only the factors that hold.

Caching. POST /api/v1/today (with /today/stored, /today/history, /today/feeling). Rows live in user_today_readings under the language plus a -c suffix, so the legacy /hub/today rows stay separate; a page is valid until the Moon leaves its centre or the local day turns. The "today" date is computed in the profile's timezone.

AI generation. claude-sonnet-4-6 writes from the computed facts only, in parallel calls: the Overview; each centre's "what this planet is doing now" and "what to do", split across three calls balanced by how much each centre has to say (each asked again once if its answer is lost); and the seven birth-chart ties (written once per chart and reused across days). A page saved under older special-day rules keeps its written cards when the day, the Moon's centre and the tier are unchanged. Each feeling card is written on first tap and cached. The prompts forbid naming any date or time not given in the facts.


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 / eclipse / transit), 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.). 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, udaya, purvahna, follows, navami_merge (see documents/domains/jyotish/calendar_observance_conventions.md for derivations). Default is sunrise, which reads the tithi at noon UTC. udaya reads it at Delhi sunrise (earlier day when a tithi spans two sunrises; a skipped tithi goes to the day it begins) and is used by the festivals Drik confirms follow it: Govardhan Puja, Durga Ashtami, Hanuman Jayanti, Buddha Purnima, Kartik Purnima. purvahna (the first two fifths of the day) is Akshaya Tritiya's.

  • 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.

Celestial events (#182) — app/services/calendar/celestial.py. After the festival/lunar pass, the scanner appends two families of significant celestial events, computed independently of festivals.yaml:

  • Eclipses (event_type: eclipse) — pyswisseph's sol_eclipse_when_glob / lun_eclipse_when forward-search enumerates every solar and (non-penumbral) lunar eclipse in range. Per eclipse the facts carry the subtype (total / annular / hybrid / partial), the Delhi-reference magnitude and visibility (sol_eclipse_how / lun_eclipse_how, plus a moon-above-horizon check for lunar), the node on the eclipse axis (Rahu/Ketu), the eclipsed luminary's rashi and the Moon's nakshatra, and the Sutak window (start = first contact − 12h for solar, − 9h for lunar). Penumbral-only lunar eclipses are skipped (no Sutak, not observed — matches panchang practice).

  • Transits / gochar (event_type: transit) — a day-over-day sign-change walk (same noon-UTC sampling as Sankranti) over Mangal, Budha, Guru, Shukra, Shani, Rahu and Ketu (Surya's sign change is the Sankranti; Chandra is too fast). The slow grahas (Guru, Shani, Rahu, Ketu) emit on every sign change; the faster grahas (Mangal, Budha, Shukra) emit only when the ingress lands in their exaltation, debilitation, or own sign. The graha's dignity in the new sign (exalted / debilitated / own / moolatrikona / neutral, via dignities.py) rides in the facts — a dignity change is always an ingress, so it is a fact on the transit row, not a separate event_type.

Comets and other Saṃhita (omen) phenomena are out of scope — they are not in the planetary ephemeris and sit outside the nine-graha tradition.

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 including Kundli Matching, Guna Milan and Mangal Dosha, 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 pulled from the calendar API at build time, so the pages and the calendar always agree.

Diwali and Dhanteras pages also carry puja times for 16 cities (London, Leicester, New York, New Jersey, Chicago, Houston, the San Francisco Bay Area, Toronto, Vancouver, Sydney, Melbourne, Perth, Auckland, Singapore, Dubai, Delhi), from GET /api/v1/calendar/muhurat (city_timings.py). For each city the festival's day rule is re-applied with that city's own sunset, so a city's date can differ from Delhi's. Pradosh Kaal is sunset plus a fifth of the night (three of the night's fifteen muhurtas), and the puja muhurat is Pradosh ∩ the tithi ∩ Vrishabha (Taurus) rising, the convention DrikPanchang publishes. The 2026 figures for Delhi, London, New York, Toronto and Sydney are pinned to Drik's within three minutes in tests/test_city_timings.py. The calendar itself is still reckoned for Delhi.

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. /match is prerendered in its signed-out form (what matching is, and the way in); the private shared-match links (/match/join/*, /match/shared/*) are disallowed in robots.txt.


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.


The Gita Reader

Data. One table for every sacred text, scripture_verses (the Gita's rows carry text = 'bhagavad-gita'; the per-text titles, chapter intros, ★ verses and theme vocabulary live in a data file, app/data/texts/bhagavad-gita.yaml) — chapter, verse, Devanagari sanskrit, IAST transliteration, english (Swami Sivananda, via the GPL-3.0 vedicscriptures dataset; 700 rows under standard scholarly numbering), keywords (2–4 per verse from a 41-term controlled vocabulary), and ashoka_commentary (a reflection of roughly 55–100 words per verse, median about 75). Keywords and reflection were generated together, once, by claude-sonnet-4-6 in the Ashoka voice. Nothing on the reader is generated at request time.

Endpoints. GET /api/v1/gita/chapters (18 chapters, English titles + Sanskrit yoga names, verse counts), /chapter/{n}, /verse/{c}/{v}, /random, and /search?q= — the search resolves a verse reference ("2.47") first, then an exact theme keyword from the controlled vocabulary, then a substring match over the English translation and transliteration. The same endpoints serve every text under GET /api/v1/texts/{text}/… (/api/v1/texts lists the library); /api/v1/gita/… is a permanent alias for /api/v1/texts/bhagavad-gita/….

Reader behaviour. /gita is the introduction / landing page (explains the Gita, its place in the Mahabharata, our reading approach + the chapter intros; Start-here / Search / Surprise-me; an 18-chapter table of contents; prerendered for SEO). Verses live at /gita/:chapter/:verse — one verse per card with previous/next navigation, left/right arrow keys, and the URL tracking the verse (/gita/2/47) so any verse is shareable; a shareable branded card PNG at /api/v1/gita/verse/{c}/{v}/card.png. Each verse shows its speaker (KRISHNA teaches / ARJUNA asks / SANJAYA narrates), the chapter-opener band on verse 1, ★ on the best-known verses, and a "verses that speak to this one" strip. Guests read the full verse text; Ashoka's reflection is trimmed to its first sentence until signed in. Bookmarks and the last-read position are stored in the browser (localStorage) in v1 — server-side progress (user_gita_progress) is the planned follow-up, and is what the School's chapter badges will read from.

Share cards

Endpoints (app/api/v1/share.py, verified users with a saved profile — the /match gate). POST /api/v1/share/story/lines takes the same chart payload as /hub and returns the picker's data: the portrait facts (rising, Moon sign + nakshatra, Ishta Devata, present Mahadasha, Sun sign + house, the hub's chart_summary as the hook), the extracted lines, and the chart. POST /api/v1/share/story/card (kind = hero | kundli | line, plus index) returns one card as PNG bytes, Cache-Control: private, cached in-process. POST /api/v1/share/match/lines and /match/card (match_hero | match_line) do the same for a Kundli Match from the pair request /match uses. Nothing is written to storage: the only bucket is the public social one.

Which story. The share resolves the hub's own cache key (chart + Mahadasha lord + age band + language) so it always reads the story the user is looking at, and answers 409 until the hub has written it; the match side uses /match's input hash the same way.

Lines (app/services/share_lines.py). One claude-sonnet-4-6 call reads the story, the chart house by house, and the hub highlights, and returns four or five candidate sentences each paired with the fact behind it. The module then enforces what the model cannot be trusted with: the card quotes the story's text for the span the model chose (matched on words, so a re-punctuated or de-glossed line never lets model wording onto a card; bracketed Sanskrit glosses are stripped from the source; a dropped leading clause gets a capital); 40–180 characters, at most five, no duplicates; a graha/house pair the chart does not confirm loses the mini chart and keeps the eyebrow; planet and sign names on the eyebrow are forced to English. The result is stored beside the prose it came from (content_json.share_lines), so it invalidates with the story. The match variant tags each line with its dimension instead of a placement.

Rendering (app/services/share_cards.py). Pure HTML builders per card type on the .personal scheme in social/template/card.css (indigo/gold, theme-invariant), a fixed-seed starfield, and a classic North Indian diamond drawn from the lagna (houses anticlockwise from the top; one house lit for a line card). Screenshotted at 2× by the social engine's Chromium pipeline (social.render._render_pngs) — 1080×1350. Glyphs need Noto Sans Symbols.

Frontend. components/share/SharePicker.jsx (a BottomSheet: tiles drawn in React from the lines endpoint, PNGs fetched only for the chosen cards, Web Share API with an open-in-tab fallback, downloads) with story and match variants; base/utils/nativeShare.js; plain-text export of the story + the Jyotish tab (storyText.js) and of the match reading (matchText.js). Spec: documents/commercial/share_feature_spec.md.

Kundli Matching

/match reads two of a verified user's saved charts side by side, for one of four relationships: considering marriage, married / partners, friends, or family. Spec: documents/domains/jyotish/kundli_matching_spec.md.

Engine. app/services/jyotish/matching.py, deterministic:

  • Ashtakoot Guna Milan (compatibility.ashtakoot, from each person's Moon sign, Moon nakshatra and the Moon's degree), scored as Drik Panchang scores it, factor for factor: its full Yoni (0–4) and Vashya (0, ½, 1, 2) tables, with Sagittarius and Capricorn changing Vashya group at 15°; Tara counting only the 3rd, 5th and 7th taras against; Graha Maitri ½ for neutral against enemy; Bhakoot 0 at 2/12, 6/8 and 5/9; the Nadi fault lifted when the Moons share a sign but not a star or a star but not a sign; and two Moons in the same star and sign losing Bhakoot and Nadi both unless they are more than 3°20′ apart. Checked against 256 fictional couples scored by Drik Panchang (tests/data/guna_milan_ground_truth.json, write-up in documents/domains/jyotish/guna_milan_audit.md). One convention of ours: profiles carry no gender and any pair can be matched, so where the classical score depends on who is the bride (Varna, Gana, and two Yoni pairs) the koota is worked out both ways round and the higher score kept (matching.guna_milan). A printout that assumes bride → groom can be a point or two lower.
  • Dimensions. Gana → Temperament, Graha Maitri → Mental rapport, Bhakoot → Emotional rhythm, Tara → Supporting each other, Vashya → Give and take, Nadi → Health and children (marriage and partners only). Each gets a state from points ÷ maximum: ≥ 0.8 flows, ≥ 0.4 steady, below that needs care. Varna counts only toward the /36.
  • Chapters now (partners only): each person's current Vimshottari Mahadasha lord. Its state is our own convention, not a classical rule: the two lords' natural (naisargika) friendship — friends both ways (or the same lord) flows, any enmity needs care, otherwise steady; Rahu and Ketu count as neutral.
  • Mangal Dosha. Mars in whole-sign house 1, 2, 4, 7, 8 or 12 counted from each of Lagna, Moon and Venus. Placed from one reference = mild, two or three = strong. Cancelled when Mars is in its own sign (Mesha, Vrishchika) or exalted (Makara); when both people carry a non-cancelled dosha it is shown as balanced between them.
  • Traditional band (marriage only): below 18, 18–24 acceptable, 25–32 good, 33+ excellent. Nadi and Bhakoot doshas are shown as flags (the koota scored 0).
  • Whole-chart comparisons — also our own conventions, on whole-sign houses:
    • Where you land in each other's life (all relationships): each Moon counted from the other's Lagna; 1, 4, 5, 7, 9, 10, 11 flow, 6, 8, 12 need care, 2 and 3 are steady; if the two directions disagree, steady.
    • What you each look for (marriage, partners): each 7th-house lord against the other's Moon-sign lord by natural friendship.
    • Intimacy and desire (marriage, partners): a tally of Yoni (the sexual-compatibility koota), Venus–Mars contact across the charts, the other's Venus or Moon in one's 12th house (shayana sukha), and whether the two drives match (drive from Mars's dignity or combustion, pace from Venus's). The 8th-house overlay and Saturn or Rahu on Venus add notes but no score.
    • Affection (marriage, partners): Venus 1st, 5th, 7th or 9th from the other's Moon or Lagna, counted both ways.
    • Where sparks fly (all relationships): Mars on, or aspecting by its 4th, 7th or 8th, the other's Moon or Lagna, counted both ways.
    • The marriage chart (marriage, partners): the D9 7th lord and Venus in each Navamsa, graded strong (own/exalted), weak (debilitated) or even.

Two tabs. "The two of you" carries a 300–450-word story written from every dimension; "The details" carries the rows (whole-chart first, then the kootas) and the tradition box.

Prose. POST /api/v1/match/facts returns the facts with no model call. POST /api/v1/match/reading has claude-sonnet-4-6 write a one-sentence headline, the story, and for each dimension a short story (citing at least one planet, house or birth star from the engine's findings) and one thing to try, in plain words with no imagery and no gendered pronouns, in the elder voice (PERSONA_ELDER, English-first terms). It speaks to "you" when the user's own chart is one of the pair (always shown on the left) and of "they" otherwise. Replies are validated per dimension (word limits, a chart mention, no advice inside the story, you/they wording). The headline and story and up to three groups of rows are written by parallel calls, rows that fail are rewritten by their own group, and the result is cached per user in user_match_readings by a hash of both people's names and chart facts, the relationship, voice, language and prose version.

A couple's opening (services/pair_opening.py; marriage and partners, English, both charts timed). Each chart's spine is picked as for the birth reading (pick_spine). Two short calls write one passage, returned as opening and shown above the story. The first paragraph says what each is here for and brings; it is checked for voice, length, both areas named, no ranking of the two, no claimed habit, no children, no planet or sign, no fate or grading language, and no wording lifted from the engine's notes. The second is written only when the charts meet: both spine houses in one sign, or a planet of one person (their rising sign's ruler or Atmakaraka for a full meeting) in the sign of the other's spine house. If the second fails its checks the first stands alone; if the first fails there is no opening and the reading is as before. The story's writer is told the opening is printed and not to restate it, and is given at most one timing sentence: a running Antardasha or Mahadasha whose lord sits in or rules that person's 7th house, with its end date and a fixed strength of counsel. A story that grades the match, calls it fated or repeats the opening is asked for once more; a sound story is never lost to that check. The Guna Milan score and the rows are untouched. The booklet does not carry the opening yet.

Shared matches. Two users can match their own primary charts with each other (/match/join/:token, /match/shared/:id; spec shared_matching_spec.md). One creates a single-use invite (POST /api/v1/match/invites) shown as a QR code or shared as a link. The token is 256 bits, stored only as its SHA-256, and expires after 24 hours, extended to 72 hours from creation the first time someone else opens it. The other user accepts it (POST /api/v1/match/invites/accept/{token}), after the same verified-account, chart and age checks as Kundli Matching. Either person can remove the match for both. The reading is one they-voice reading by name, shared by both people, stored per match and language in shared_match_readings and replaced when either chart changes; a Postgres advisory lock keeps two first opens to one model call. Neither person receives the other's birth date, time, place or profile id; the match page does show the placements its rows are built from (Moon, birth star, ascendant, Venus, Mars, Mahadasha, Navamsa grades).

A chart with no birth time

Spec: documents/architecture/no_birth_time_spec.md. Ticking "I don't know my birth time" saves no time (null), for the reader's own chart and for family charts; nothing is defaulted to 12:00, and editing such a chart opens with the box ticked.

Method. No ascendant is computed. The 1st house is the sidereal sign the Sun occupies at local noon (Surya Lagna), and the houses run whole-sign from it. The grahas take their noon positions. No rising sign, Lagna degree or Ascendant is named or drawn; the Kundli is tagged SURYA LAGNA.

The whole-day scan. app/services/jyotish/day_scan.py: scan_day(date, lat, lng, tz) runs the ordinary chart pipeline (calculate_chart) at 25 local moments, each hour from 00:00 to 23:00 plus 23:59, and returns:

  • the noon chart, with lagna_sign set to the Sun's noon sign;
  • sun_changes_sign;
  • per graha, the distinct signs, nakshatras and Navamsa signs it took during the day, where there was more than one (sign_changes, nakshatra_changes, d9_changes);
  • the grahas combust at some moments only (combust_changes);
  • the Atmakaraka, or None when it changed during the day;
  • holds(fn): the distinct values any fact takes across the 25 moments.

The scan is cached per (date, place). A fact with one value is stated. A fact with more than one is shown as "A or B" where that reads naturally (the Moon's sign, its nakshatra, the koota score) and dropped otherwise (the Ishta Devata, a yoga, the Atmakaraka and Karakamsha). House highlights survive only if the detectors find them at every moment, with the 1st house held at the Sun's noon sign (stable_house_facts in hub.py). A graha whose sign the day does not settle is drawn at its noon place with a dashed ring.

How often the day decides. The Moon moves about 13° a day. It changes sign on about 4 days in 10, and nakshatra (13°20') on almost every day. The Sun moves about 1° a day and changes sign on about 1 day in 30.

Sun-change day. The houses count from the Sun's noon sign. The chart and the reading say the Sun changed sign that day, so the houses may be one sign out, and a birth time settles it. The prompt is told not to lean on the house the Sun's own sign makes. Nothing is refused; the whole-day rule applies to everything else, with the 1st house held fixed.

The server decides. Each chart-taking endpoint (hub story, soul, dasha, reading, houses, today, share, match, panchang, calendar, hero, sky) resolves the no-time state from the birth details (hub.resolve_no_time) and overwrites any lagna_sign, graha positions or no-time flag the client sent. /chart/calculate accepts a null time and returns time_unknown, sun_changes_sign, moon_signs and moon_nakshatras, with the Sun's sign as lagna_sign.

Prompts and validation. no_time_prompt_rules adds one block to every reading prompt: the 1st house is the Sun's sign; no rising sign or Ascendant is mentioned; a graha whose sign changed that day is not named, and a house it sits in is read as if it were empty; unsettled nakshatras are not named; Dashas are not mentioned. no_time_avoid is the matching validator: text naming a rising sign or the Ascendant, or a graha whose sign the day does not settle, is rejected and rewritten. Every reading cache key carries a no-time marker, so a no-time reading never collides with a timed one.

What waits for a time. Chapters 3 (Your Lagna), 5 (The Karmic Layer, D60), 7 (Dashas) and 9 (Your Gemstone) return "needs a birth time" and stay in the chapter list with that tag. The Lagna is unknown by definition; the D60 moves every two minutes or so; the Vimshottari balance depends on the Moon's exact degree at birth, so a day's uncertainty can shift the dates by years; the ratna is chosen from the Lagna. The hub story carries no Dasha thread and no gemstone, and Soul & Karma drops the D9 rising sign.

Kundli Matching. Rows that use the ascendant count from that person's Sun sign. Mangal Dosha is checked from the Moon and Venus only. The marriage-chart (D9) row is hidden. When a Moon's nakshatra changed during the day of birth, Ashtakoot is scored for each nakshatra that Moon held and the total is shown both ways ("22 or 25 / 36"); each koota that differs shows both results, and a dosha that depends on it is flagged "depends on the birth time".

Panchang, calendar, sky, hero. Only the parts that hold all day are used: Chandrabala from the natal Moon's sign when it held, Taarabala only when the nakshatra held (rare). Otherwise the personal layer is hidden with one line saying why. Transit houses (Today, the sky feed) count from the Sun's sign. The hero greeting reads from the Sun's sign, with no Dasha. The chart pop-out's subtitle says "time not known".

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 (Chara Dasha readings, Jaimini aspects) are not implemented. From Jaimini we use the Atmakaraka, Karakamsha, Ishta Devata, Arudha Lagna and Upapada.
  • Age states (baladi avastha) are not used: judged by degree alone they would call most Atmakarakas "dead". The death-giving houses (maraka, badhaka) are left out by choice.
  • Yoga library is non-exhaustive.
  • Birth-time rectification is not implemented. A chart with no time is read from the Sun's sign under the whole-day rule, and Your Lagna, the D60, Dashas and the gemstone wait for a time. A rising degree within 1.5° of a sign edge is named in the reading.
  • Per-Kakshya Ashtakavarga, Yogini and Chara Dasha display, and divisional charts beyond D9 / D60 are computed but not yet displayed in the UI; the readings use the D10, D7 and D12 by planet placement only.
  • 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.
  • Kundli Matching flags Nadi and Bhakoot dosha but does not apply their traditional cancellations. Its whole-chart comparison covers the Moon overlay, the 7th house, Venus, Mars and the Navamsa 7th lord and Venus — not Jupiter, the other houses, or Navamsa house overlays.
  • 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