panchang-ts 4.3.1 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +890 -228
  2. package/dist/calendar/eclipsesTable.cjs +13 -358
  3. package/dist/calendar/eclipsesTable.d.cts +33 -23
  4. package/dist/calendar/eclipsesTable.d.ts +33 -23
  5. package/dist/calendar/eclipsesTable.js +10 -357
  6. package/dist/calendar/festivalsTable.cjs +48 -25335
  7. package/dist/calendar/festivalsTable.d.cts +33 -23
  8. package/dist/calendar/festivalsTable.d.ts +33 -23
  9. package/dist/calendar/festivalsTable.js +45 -25334
  10. package/dist/calendar/moonPhasesTable.cjs +55 -6791
  11. package/dist/calendar/moonPhasesTable.d.cts +33 -21
  12. package/dist/calendar/moonPhasesTable.d.ts +33 -21
  13. package/dist/calendar/moonPhasesTable.js +52 -6790
  14. package/dist/{eclipsesTableTypes-D-tSQMQd.d.cts → eclipsesTableTypes-BFYWRA44.d.cts} +11 -1
  15. package/dist/{eclipsesTableTypes-D-tSQMQd.d.ts → eclipsesTableTypes-BFYWRA44.d.ts} +11 -1
  16. package/dist/festivalsTableTypes-CzDgY4zS.d.cts +100 -0
  17. package/dist/festivalsTableTypes-CzDgY4zS.d.ts +100 -0
  18. package/dist/index.cjs +29098 -5938
  19. package/dist/index.d.cts +1102 -255
  20. package/dist/index.d.ts +1102 -255
  21. package/dist/index.js +29081 -5939
  22. package/dist/moonPhasesTableTypes-9XLibNCZ.d.cts +103 -0
  23. package/dist/moonPhasesTableTypes-9XLibNCZ.d.ts +103 -0
  24. package/dist/muhurta/muhurtaTable.cjs +53 -0
  25. package/dist/muhurta/muhurtaTable.d.cts +47 -0
  26. package/dist/muhurta/muhurtaTable.d.ts +47 -0
  27. package/dist/muhurta/muhurtaTable.js +47 -0
  28. package/dist/muhurtaTableTypes-CgBom297.d.cts +81 -0
  29. package/dist/muhurtaTableTypes-CgBom297.d.ts +81 -0
  30. package/package.json +23 -6
  31. package/dist/festivalsTableTypes-GePaspwd.d.cts +0 -52
  32. package/dist/festivalsTableTypes-GePaspwd.d.ts +0 -52
  33. package/dist/moonPhasesTableTypes-TH0T5rVD.d.cts +0 -56
  34. package/dist/moonPhasesTableTypes-TH0T5rVD.d.ts +0 -56
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  Pure TypeScript Hindu Panchang (almanac), Jyotish, and Birth Chart calculations.
6
6
  Zero native dependencies. Works offline in React Native (Hermes), Node.js, and browsers.
7
7
 
8
- **Fast** (~0.1 ms names-only, ~0.5 ms full) · **Typed** (full TypeScript) · **Offline** (pure JS math) · **8,164 tests across 100 files**
8
+ **Fast** (~0.25 ms trimmed, ~0.41 ms full) · **Typed** (full TypeScript) · **Offline** (pure JS math) · **8,368 tests across 121 files**
9
9
 
10
10
  ---
11
11
 
@@ -31,26 +31,84 @@ const result = getDailyPanchang(
31
31
  // → DailyPanchangResult | null. Null only at polar latitudes where
32
32
  // sunrise can't be computed. Anywhere else, narrow with `if (!result) return;`.
33
33
 
34
- console.log(result!.tithis[0].name); // "Krishna Chaturdashi"
35
- console.log(result!.nakshatras[0].name); // "Mrigashira"
36
- console.log(result!.vara.name); // "Mangalawara"
37
- console.log(result!.chandramasa.name); // "Magha"
38
- console.log(result!.samvat.vikramSamvat); // 2081
34
+ console.log(result!.angas.tithis[0].name); // "Krishna Chaturdashi"
35
+ console.log(result!.angas.nakshatras[0].name); // "Mrigashira"
36
+ console.log(result!.angas.vara.name); // "Mangalawara"
37
+ console.log(result!.calendar.chandramasa.name); // "Magha"
38
+ console.log(result!.calendar.samvat.vikramSamvat); // 2081
39
39
  ```
40
40
 
41
41
  ### Reading Output Times
42
42
 
43
- All `Date` objects are **offset-adjusted** to the requested timezone. Read time
44
- components via `getUTC*` `.getHours()` would use your system zone:
43
+ Every `Date` in a result is a **real instant** `.getTime()` is the correct
44
+ epoch millisecond. Every instant has a `*Local` companion: an offset-carrying
45
+ ISO 8601 string, which is what you want for display.
45
46
 
46
47
  ```typescript
47
- const sunrise = result!.sunrise;
48
- const h = sunrise.getUTCHours(); // 7
49
- const m = sunrise.getUTCMinutes(); // 4 → 07:04 local time
48
+ result!.sun.rise; // Date 2025-01-14T01:39:44.172Z (the actual moment)
49
+ result!.sun.riseLocal; // "2025-01-14T07:09:44.172+05:30"
50
+ result!.inauspicious.rahuKalam.start; // Date
51
+ result!.inauspicious.rahuKalam.startLocal;
52
+
53
+ // Just the wall clock:
54
+ result!.sun.riseLocal.slice(11, 16); // "07:09"
55
+
56
+ // Anything else works too, because the Date is genuinely correct:
57
+ new Intl.DateTimeFormat('en-IN', { timeZone: 'Asia/Kolkata', timeStyle: 'short' })
58
+ .format(result!.sun.rise); // "7:09 am"
59
+ Temporal.Instant.from(result!.sun.riseLocal);
50
60
  ```
51
61
 
52
- `moonrise` / `moonset` can be `null` the Moon occasionally does not rise or
53
- set on a given calendar day, which is normal.
62
+ For an instant you derive yourself, `formatInZone` renders it the same way:
63
+
64
+ ```typescript
65
+ import { formatInZone } from 'panchang-ts';
66
+ const noon = new Date((result!.sun.rise.getTime() + result!.sun.set.getTime()) / 2);
67
+ formatInZone(noon, result!.timezone.offsetMinutes); // "2025-01-14T12:27:31.086+05:30"
68
+ ```
69
+
70
+ > **Changed in v5 — this is the breaking change most likely to affect you.**
71
+ > Through 4.x every published `Date` was the true instant *shifted* by the UTC
72
+ > offset, and the README told you to read it back with `getUTC*`. That worked
73
+ > only as long as you did nothing else with the value: `JSON.stringify` emitted
74
+ > a wrong instant labelled `Z`, `Intl` with a `timeZone` rendered 12:39 pm for
75
+ > an 07:09 am sunrise, and any comparison, diff, database write, date-fns or
76
+ > Temporal call was off by the offset.
77
+ >
78
+ > Migration is mechanical: `x.getUTCHours()` → read `xLocal`, or format the
79
+ > instant. See [Upgrading from 4.x](#upgrading-from-4x).
80
+
81
+ `moon.rise` / `moon.set` can be `null` — the Moon occasionally does not rise or
82
+ set on a given calendar day, which is normal. `moon.riseLocal` / `moon.setLocal`
83
+ are `null` exactly when they are.
84
+
85
+ ### The result is grouped
86
+
87
+ `DailyPanchangResult` has seven groups plus a handful of top-level fields.
88
+ Through 4.x it was ~50 flat fields; the groups are what tell you where to look.
89
+
90
+ | Group | Holds |
91
+ |---|---|
92
+ | `sun` | `rise` / `set` / `nextRise` (+ `*Local`), day and night lengths, the Sun's `siderealLongitude` and `nakshatra` |
93
+ | `moon` | `rise` / `set` (+ `*Local`), the Moon's `siderealLongitude` and `rashi` |
94
+ | `angas` | the five limbs — `tithis`, `nakshatras`, `yogas`, `karanas`, `vara` |
95
+ | `calendar` | `masa` (solar), `chandramasa` (lunar), `samvat` |
96
+ | `muhurtas` | `abhijit`, `brahma`, `vijaya`, `godhuli`, `nishita`, `amritKala`, `madhyahna`, `pratahSandhya`, `sayahnaSandhya`, `doGhati` |
97
+ | `inauspicious` | `rahuKalam`, `gulikaKalam`, `yamaganda`, `durMuhurta`, `varjyam`, `bhadra`, `gandaMula`, `panchaka`, `panchakaInfo`, `panchakaRahita` |
98
+ | `periods` | `choghadiya`, `hora`, `gowri` |
99
+
100
+ Top level: `date`, `location`, `timezone`, `ayanamsa`, `specialYogas`,
101
+ `anandadiYoga`, `festivals`, `eclipse`, `chandraBalam`, `tarabala`.
102
+
103
+ **Nothing is optional.** Every field is always present. A value that does not
104
+ apply is `null`; a collection that does not apply is `[]`. That holds whether
105
+ the reason is the domain (no Bhadra window today) or your options (you did not
106
+ pass `janmaRashi`, so `chandraBalam` is `null`) — the result *shape* never
107
+ depends on what you passed.
108
+
109
+ `getInstantPanchang` uses the same group names for the subset an instant can
110
+ answer: `sun`, `moon`, `angas`, `calendar`, `inauspicious`. There is no
111
+ `muhurtas` or `periods`, because those are properties of a Hindu *day*.
54
112
 
55
113
  ### `getDailyPanchang` vs `getInstantPanchang`
56
114
 
@@ -66,6 +124,329 @@ Ekadashi split. For reliable festival dating, use `getDailyPanchang`.
66
124
 
67
125
  ---
68
126
 
127
+ ## Upgrading from 4.x
128
+
129
+ Three changes move numbers that 4.x produced, and one option is gone.
130
+
131
+ ### Lahiri ayanamsa corrected by +38″
132
+
133
+ The library's Lahiri constant sat 38 arcseconds behind DrikPanchang's — it used
134
+ `23.853211°` at J2000 (the widely-repeated 23° 51′ 11.6″ figure) where Drik
135
+ computes `23.863801°`. The replacement was solved from Drik's own published
136
+ values across 1950–2050, which agree on it to within 0.01″ — a century-wide
137
+ baseline, so the precession polynomial is pinned too, not just the epoch
138
+ constant. Every sidereal output moves with it:
139
+
140
+ | Output | Effect |
141
+ |---|---|
142
+ | Nakshatra end-times | ~69 s later than 4.x (carries the ayanamsa once) |
143
+ | Yoga end-times | ~129 s later than 4.x (carries it twice) |
144
+ | Planetary longitudes, rashi, pada, lagna, divisionals, dashas | shifted +0.0106° |
145
+ | Tithi / karana end-times | unchanged — Moon − Sun cancels the ayanamsa |
146
+ | Raman / KP / True Chitra / Thirukanitham | moved by the same +38″; their offsets from Lahiri are preserved |
147
+
148
+ Worst-case end-time drift vs Drik dropped from 131 s to 60 s, and the sign split
149
+ by ayanamsa exposure — nakshatra and yoga early, tithi and karana late — is gone.
150
+ If you have snapshot tests or cached charts from 4.x, expect them to need
151
+ re-pinning.
152
+
153
+ ### ΔT now uses measurement, so every published time moves ~6 s
154
+
155
+ 4.x took ΔT (TT − UT) entirely from Espenak–Meeus. Its post-2005 branches are an
156
+ extrapolation published in 2006, and Earth's rotation did not follow it — by
157
+ 2026 the model reads about **5.9 s high**, drifting a further ~0.6 s each year.
158
+ Because this library reports *times*, that lands directly on published values.
159
+
160
+ v5 takes ΔT from the leap-second chain (`32.184 + (TAI − UTC)`, exact, and
161
+ within the 0.9 s band leap seconds maintain) wherever ΔT has actually been
162
+ measured, and resumes Espenak–Meeus beyond it carrying the offset it had
163
+ accrued. Against JPL Horizons the measured era now agrees to **0.005 s** at
164
+ every decade from 1980, where 4.x was seconds out.
165
+
166
+ | Output | Effect |
167
+ |---|---|
168
+ | Tithi / nakshatra / yoga / karana end-times | ~5.7 s later for 2025 dates, growing with the model's drift |
169
+ | Sankranti and other transit instants | same shift — it is one uniform correction, not per-element |
170
+ | Sunrise / sunset / moonrise / moonset | barely moved — the error scales against the 15°/hr sky rotation |
171
+ | Dates a panchang element is *filed under* | unchanged except where a transit sits within seconds of sunrise |
172
+
173
+ That last row is the one to know about. `computeSankrantisForYear` publishes a
174
+ date, and the date is decided by whether the transit precedes sunrise. The 2025
175
+ Tula Sankranti at Reykjavik is such a case: it still falls on Oct 16, but its
176
+ margin narrowed from 7.1 s to 1.4 s. Locations at high latitude with a transit
177
+ near sunrise are where a day could flip.
178
+
179
+ ### Instant-mode vara was wrong after ~19:00
180
+
181
+ `getInstantPanchang` located sunrise by searching forward from `date − 12 h`.
182
+ For an evening instant that start point is already past the morning's sunrise,
183
+ so it found *tomorrow's* and rolled the weekday back a day. Any query after
184
+ roughly 7 pm returned the previous vara — and with it the wrong Rahu Kalam,
185
+ Gulika Kalam, Yamaganda, Choghadiya, Hora, Anandadi yoga and special yogas.
186
+ `getDailyPanchang` was never affected. If you cached instant-mode results from
187
+ 4.x for evening timestamps, discard them.
188
+
189
+ ### `precision` removed
190
+
191
+ `precision: 'standard' | 'high'` and the `Precision` type no longer exist.
192
+ Element transitions are now solved by secant iteration, which converges to the
193
+ root rather than stopping at a fixed tolerance, so there is nothing left for the
194
+ option to select — and the tighter setting no longer buys anything. Removing it
195
+ from your options object is the whole migration; leaving it in is a type error,
196
+ not a silent no-op.
197
+
198
+ ### Sunrise is single-valued per location-day
199
+
200
+ Solar rise/set is computed from a canonical anchor and cached per location-day,
201
+ so it no longer depends on which instant the caller happened to start searching
202
+ from. Values shift by ≤108 ms vs 4.x, and two calls for the same day now agree
203
+ exactly instead of differing by up to 109 ms. Windows derived proportionally
204
+ from the day length — Varjyam, Bhadra, the slot systems — move by a little more
205
+ than that. This removes an inconsistency rather than introducing an
206
+ approximation: 4.x returned a different sunrise depending on which caller asked.
207
+
208
+ ### Moonrise and moonset are single-valued per location-day
209
+
210
+ The same treatment sunrise received, now applied to the Moon. `getMoonrise` /
211
+ `getMoonset` resolve through a canonical per-UTC-day cache, so an event has one
212
+ timestamp no matter which caller asks or from which instant they searched.
213
+ Published `moon.rise` / `moon.set` shift by **≤182 ms** vs 4.x. Nothing else in the
214
+ result moves — verified over 11,520 daily results across six locations and three
215
+ centuries: zero changes to any index, name, boolean, festival date or other
216
+ timestamp.
217
+
218
+ ### `read*` for tables, `compute*` for the engine
219
+
220
+ `getFestivalsForYear` read a pre-built table; `getFestivalsInRange` ran the
221
+ engine. Two near-identical names, completely different inputs and semantics. v5
222
+ settles one convention across all four families — festivals, eclipses, moon
223
+ phases and muhurta:
224
+
225
+ | 4.x | v5 | what it does |
226
+ |---|---|---|
227
+ | `getFestivalsForYear` | `readFestivalsForYear` | reads a table |
228
+ | `getFestivalsForDate` | `readFestivalsForDate` | reads a table |
229
+ | `getFestivalsYearRange` | `readFestivalsYearRange` | reads a table |
230
+ | `getEclipsesForYear` / `ForDate` / `YearRange` | `readEclipsesForYear` / … | reads a table |
231
+ | `getMoonPhasesForYear` / `ForDate` / `YearRange` | `readMoonPhasesForYear` / … | reads a table |
232
+ | — | `readMuhurtaForYear` / `ForDate` / `YearRange` | **new** — reads a table |
233
+ | — | `readBestMuhurtaDays` | **new** — top-scoring days |
234
+ | `getFestivalsInRange` | `computeFestivalsInRange` | runs the engine |
235
+ | `getEclipsesInRange` | `computeEclipsesInRange` | runs the engine |
236
+ | `getMoonPhasesInRange` | `computeMoonPhasesInRange` | runs the engine |
237
+ | `findAuspiciousDates` | `computeAuspiciousDatesInRange` | runs the engine |
238
+ | `getEkadashiDatesForYear` | `computeEkadashiDatesForYear` | runs the engine |
239
+ | `getSankrantisForYear` | `computeSankrantisForYear` | runs the engine |
240
+
241
+ **Every 4.x name still works** — they are deprecated aliases pointing at the same
242
+ functions, kept through v5. Nothing breaks today; the old names will go in v6.
243
+
244
+ New single-year entry points, the shape most callers reach for first:
245
+
246
+ ```ts
247
+ computeFestivalsForYear(2027, location, { timezone: 330 });
248
+ computeEclipsesForYear(2027, location, { timezone: 330 });
249
+ computeMoonPhasesForYear(2027, { timezone: 330 });
250
+ computeAuspiciousDatesForYear(2027, vivahRule, location, { timezone: 330 });
251
+ ```
252
+
253
+ ### `buildMuhurtaTable` + the `panchang-ts/muhurta` subpath
254
+
255
+ Festivals, eclipses and moon phases each had a builder and an engine-free
256
+ reader; muhurta had neither, so finding auspicious dates meant running the full
257
+ engine on device for every query. v5 completes the family:
258
+
259
+ ```ts
260
+ // build once (build time, or first launch), then persist the JSON
261
+ import { buildMuhurtaTable, vivahRule } from 'panchang-ts';
262
+ const table = buildMuhurtaTable({
263
+ rule: vivahRule, location: DELHI, timezoneOffsetMinutes: 330,
264
+ startYear: 2026, endYear: 2031,
265
+ });
266
+
267
+ // read it back with no astronomy code in the bundle (~1.7 KB)
268
+ import { readBestMuhurtaDays } from 'panchang-ts/muhurta';
269
+ readBestMuhurtaDays(table, 5); // top 5 days, highest score first
270
+ ```
271
+
272
+ Only days that pass the rule are stored unless you pass `includeFailures: true`.
273
+
274
+ ### Built tables are dictionary-encoded and carry `key`
275
+
276
+ `build*Table` now emits a `_dict` of unique entries with each day holding
277
+ indices, rather than repeating every localized string at every occurrence. A
278
+ 10-year festival table goes from 315 KB to **89.9 KB (28.5%)**; a 10-year
279
+ moon-phase table from 106 KB to **29.7 KB (28.0%)**. Resolved output is
280
+ identical — verified across every year and both locales.
281
+
282
+ Gzip already hid most of this on the wire, so the win is **parse time and
283
+ resident memory**, which is the constraint that actually bites on Hermes.
284
+
285
+ Festival table entries also gained the stable `key` the engine has been
286
+ returning since 4.x, so a table is no longer both larger *and* less useful than
287
+ engine output.
288
+
289
+ **Tables you already cached still read.** The reader detects the format, so a v1
290
+ table built with 4.x keeps working; only `key` is unavailable from it, and comes
291
+ back as `''`.
292
+
293
+ ### Published `Date`s are real instants — the flagship change
294
+
295
+ ```diff
296
+ - result.sunrise.getTime() // NOT when sunrise happened (off by the UTC offset)
297
+ - result.sunrise.getUTCHours() // the documented 4.x idiom
298
+ + result.sun.rise.getTime() // correct epoch ms
299
+ + result.sun.riseLocal // "2025-01-14T07:09:44.172+05:30"
300
+ ```
301
+
302
+ Applies to **every** `Date` in `DailyPanchangResult` and to every `TimePeriod` —
303
+ `sun.rise`, `sun.set`, `sun.nextRise`, `moon.rise`, `moon.set`, all the muhurtas
304
+ and inauspicious periods, all four slot systems, the element
305
+ `startTime`/`endTime` arrays, the eclipse contacts and sutak window. Each gains
306
+ a `*Local` companion: `sun.riseLocal`, `inauspicious.rahuKalam.startLocal`,
307
+ `angas.tithis[0].endTimeLocal`, and so on.
308
+
309
+ | you had | you now write |
310
+ |---|---|
311
+ | `r.sun.rise.getUTCHours()` | `r.sun.riseLocal.slice(11, 13)` |
312
+ | `` `${h}:${m}` `` from `getUTC*` | `r.sun.riseLocal.slice(11, 16)` |
313
+ | `r.inauspicious.rahuKalam.start.getUTCHours()` | `r.inauspicious.rahuKalam.startLocal.slice(11, 13)` |
314
+ | `r.angas.tithis[0].endTime` for display | `r.angas.tithis[0].endTimeLocal` |
315
+ | a `Date` you derived yourself | `formatInZone(d, r.timezone.offsetMinutes)` |
316
+
317
+ `getInstantPanchang` results carry **no** `*Local` fields: that call takes no
318
+ timezone, so there is no zone to render a wall clock in.
319
+
320
+ **Cost:** rendering the strings adds ~0.04 ms per daily panchang — invisible on
321
+ a cold call (0.7885 → 0.7924 ms) and ~20% of a fully cached warm one
322
+ (0.176 → 0.215 ms). Both pairs are the tree measured against itself when the
323
+ change landed, mid-Phase-36; the release finally warms to **0.17 ms**, against
324
+ published 4.3.1's **6.20 ms**.
325
+
326
+ ### `result.timezone` is now an object
327
+
328
+ ```diff
329
+ - result.timezone // 330
330
+ + result.timezone // { offsetMinutes: 330, zone: 'Asia/Kolkata' }
331
+ + result.timezone.offsetMinutes
332
+ ```
333
+
334
+ `options.timezone` has always accepted `number | string`, but the result carried
335
+ only a number — so passing `'America/New_York'` produced a result that could not
336
+ say which zone produced it. `zone` is present only when you passed a zone name.
337
+
338
+ **The DST limit, now stated explicitly.** The offset is resolved **once per
339
+ call** from a reference date, so a Hindu day containing a DST transition is
340
+ computed at a single offset throughout. Correct for almost every day; on the one
341
+ or two transition days a year, times after the jump are shifted by its size.
342
+ 4.x documented this as a blanket "DST resolves automatically", which was not the
343
+ whole truth.
344
+
345
+ ### The result object is grouped
346
+
347
+ `DailyPanchangResult` had ~50 flat top-level fields mixing five categories.
348
+ v5 sorts them into seven groups — see [The result is grouped](#the-result-is-grouped)
349
+ for the full table. This is a large break, and it lands in the same release as
350
+ the `Date` change on purpose: migrating both at once is one pass over your read
351
+ sites, not two.
352
+
353
+ Every rename, in full:
354
+
355
+ | 4.x | v5 |
356
+ |---|---|
357
+ | `sunrise` / `sunset` / `nextSunrise` | `sun.rise` / `sun.set` / `sun.nextRise` |
358
+ | `sunriseLocal` / `sunsetLocal` / `nextSunriseLocal` | `sun.riseLocal` / `sun.setLocal` / `sun.nextRiseLocal` |
359
+ | `dayDurationMinutes` / `nightDurationMinutes` | `sun.dayDurationMinutes` / `sun.nightDurationMinutes` |
360
+ | `dinamanaMinutes` / `ratrimanaMinutes` | `sun.dinamanaMinutes` / `sun.ratrimanaMinutes` |
361
+ | `siderealSunAtSunrise` | `sun.siderealLongitude` |
362
+ | `suryaNakshatra` | `sun.nakshatra` |
363
+ | `moonrise` / `moonset` | `moon.rise` / `moon.set` |
364
+ | `moonriseLocal` / `moonsetLocal` | `moon.riseLocal` / `moon.setLocal` |
365
+ | `siderealMoonAtSunrise` | `moon.siderealLongitude` |
366
+ | `chandraRashi` | `moon.rashi` |
367
+ | `tithis` / `nakshatras` / `yogas` / `karanas` / `vara` | `angas.*` (same names) |
368
+ | `masa` / `chandramasa` / `samvat` | `calendar.*` (same names) |
369
+ | `abhijitMuhurta` / `brahmaMuhurta` / `vijayaMuhurta` | `muhurtas.abhijit` / `muhurtas.brahma` / `muhurtas.vijaya` |
370
+ | `godhuliMuhurta` / `nishitaMuhurta` | `muhurtas.godhuli` / `muhurtas.nishita` |
371
+ | `amritKala` / `madhyahna` / `pratahSandhya` / `sayahnaSandhya` | `muhurtas.*` (same names) |
372
+ | `doGhatiMuhurta` | `muhurtas.doGhati` |
373
+ | `rahuKalam` / `gulikaKalam` / `yamaganda` / `durMuhurta` | `inauspicious.*` (same names) |
374
+ | `varjyam` / `bhadra` / `gandaMula` / `panchaka` / `panchakaRahita` | `inauspicious.*` (same names) |
375
+ | `choghadiya` / `hora` | `periods.choghadiya` / `periods.hora` |
376
+ | `gowriPanchangam` | `periods.gowri` |
377
+ | `eclipse.magnitude` (disc *area*) | `eclipse.obscuration` — same value; the new `eclipse.magnitude` is the *diameter* fraction catalogues publish, and is negative for a penumbral lunar eclipse |
378
+
379
+ Unmoved: `date`, `location`, `timezone`, `ayanamsa`, `specialYogas`,
380
+ `anandadiYoga`, `festivals`, `eclipse`, `chandraBalam`, `tarabala`.
381
+
382
+ For `getInstantPanchang`: `tithi` / `nakshatra` / `yoga` / `karana` / `vara` →
383
+ `angas.*`; `siderealSun` → `sun.siderealLongitude`; `siderealMoon` →
384
+ `moon.siderealLongitude`; `suryaNakshatra` → `sun.nakshatra`; `chandraRashi` →
385
+ `moon.rashi`; `chandramasa` / `samvat` → `calendar.*`; `panchaka` / `gandaMula`
386
+ → `inauspicious.*`.
387
+
388
+ ### One rule for "not applicable": always present, `null` or `[]`
389
+
390
+ 4.x used three conventions and you could not predict which you would get —
391
+ `| null` for `bhadra` / `varjyam` / `eclipse`, `?`-optional for `chandraBalam` /
392
+ `tarabala`, and an empty array for `panchakaRahita` / `festivals`. v5 has one
393
+ rule: **every field is always present**, a value that does not apply is `null`,
394
+ and a collection that does not apply is `[]`.
395
+
396
+ ```diff
397
+ - if ('chandraBalam' in r) … // 4.x: field absent without janmaRashi
398
+ - r.chandraBalam!.house // and the `!` was mandatory
399
+ + if (r.chandraBalam !== null) … // v5: always present, null when unasked
400
+ + r.chandraBalam?.house
401
+ ```
402
+
403
+ Only `chandraBalam` and `tarabala` changed behaviour; everything else already
404
+ followed the rule. `toBeUndefined()`-style checks against them become
405
+ `toBeNull()`.
406
+
407
+ ### `suryaNakshatra` is typed as a nakshatra, not a rashi
408
+
409
+ Now published as `sun.nakshatra`. Its `index` has always been 0..26 (Ashwini …
410
+ Revati). Its *type* said `RashiInfo`, documented "0 = Mesha … 11 = Meena", so
411
+ anyone indexing a 12-element rashi array by it got silent garbage for two thirds
412
+ of the year. The runtime value is unchanged; the type is now
413
+ `NakshatraIndexInfo` and TypeScript will point at the misuse.
414
+
415
+ ### `_debug` removed
416
+
417
+ `DailyPanchangResult._debug` was declared in the published type and written
418
+ nowhere in the library. It never carried data. If you referenced it, it was
419
+ always `undefined`.
420
+
421
+ ### Alias fields documented rather than removed
422
+
423
+ `sun.dinamanaMinutes` / `sun.dayDurationMinutes` and `sun.ratrimanaMinutes` /
424
+ `sun.nightDurationMinutes` are the same numbers under classical and English
425
+ names. Both pairs stay — consumers use both vocabularies — and the types now say
426
+ plainly that they are aliases, never independently computed.
427
+
428
+ `calendar.chandramasa` keeps its casing beside `moon.rashi` and `sun.nakshatra`.
429
+ Renaming it would break every consumer for a casing preference. The type now
430
+ documents that `calendar.masa` is the **solar** month and `calendar.chandramasa`
431
+ the **lunar** one, which was previously left to guesswork.
432
+
433
+ ### Additive, but worth knowing
434
+
435
+ - `EclipseInfo` / `EclipseSubtype` are now exported. 4.x shipped
436
+ `getUpcomingLunarEclipse` and friends without the type they return.
437
+ - `festivals[].key` — stable, language-independent festival id. Match on this,
438
+ never on `name`.
439
+ - `bhadra.locationName` — localized display name; `bhadra.location` stays the
440
+ machine-readable key.
441
+ - `MuhurtaScore.factors` — structured scoring inputs alongside English `reasons`.
442
+ - `BirthChart.byPlanet` — the nine placements keyed by graha.
443
+ - `eclipse.description` is now localized. Under `language: 'hi'` it was
444
+ previously emitted in English, including inside `festivals[].description`.
445
+ - `sections` on `getDailyPanchang` — opt into a narrower, cheaper call. See
446
+ [Performance](#performance).
447
+
448
+ ---
449
+
69
450
  ## Features at a Glance
70
451
 
71
452
  | Category | Features |
@@ -112,16 +493,16 @@ over per-feature helpers.
112
493
  ```typescript
113
494
  const r = getDailyPanchang(date, location, { timezone: 330 })!;
114
495
 
115
- r.tithis.forEach(t => console.log(t.name, t.paksha, t.completionPercentage, t.endTime));
116
- r.nakshatras.forEach(n => console.log(n.name, n.pada, n.endTime));
117
- r.yogas.forEach(y => console.log(y.name, y.endTime));
118
- r.karanas.forEach(k => console.log(k.name, k.type, k.endTime));
119
- console.log(r.vara.name, r.vara.englishName); // "Mangalawara", "Tuesday"
496
+ r.angas.tithis.forEach(t => console.log(t.name, t.paksha, t.completionPercentage, t.endTime));
497
+ r.angas.nakshatras.forEach(n => console.log(n.name, n.pada, n.endTime));
498
+ r.angas.yogas.forEach(y => console.log(y.name, y.endTime));
499
+ r.angas.karanas.forEach(k => console.log(k.name, k.type, k.endTime));
500
+ console.log(r.angas.vara.name, r.angas.vara.englishName); // "Mangalawara", "Tuesday"
120
501
 
121
502
  // Single-instant snapshot:
122
503
  import { getInstantPanchang } from 'panchang-ts';
123
504
  const i = getInstantPanchang(new Date(), location)!;
124
- console.log(i.tithi.name, i.nakshatra.name, i.yoga.name, i.karana.name, i.vara.name);
505
+ console.log(i.angas.tithi.name, i.angas.nakshatra.name, i.angas.yoga.name, i.angas.karana.name, i.angas.vara.name);
125
506
  ```
126
507
 
127
508
  ## Lunar & Solar Calendar
@@ -129,16 +510,16 @@ console.log(i.tithi.name, i.nakshatra.name, i.yoga.name, i.karana.name, i.vara.n
129
510
  ```typescript
130
511
  const r = getDailyPanchang(date, loc, { timezone: 330, masaSystem: 'purnimanta' })!;
131
512
 
132
- r.chandramasa.name; // active system (default: Purnimanta / North Indian)
133
- r.chandramasa.amantaName; // South Indian
134
- r.chandramasa.purnimantaName; // North Indian
135
- r.chandramasa.isAdhika; // true during leap months
136
- r.samvat.vikramSamvat; // 2081
137
- r.samvat.shakaSamvat; // 1946
513
+ r.calendar.chandramasa.name; // active system (default: Purnimanta / North Indian)
514
+ r.calendar.chandramasa.amantaName; // South Indian
515
+ r.calendar.chandramasa.purnimantaName; // North Indian
516
+ r.calendar.chandramasa.isAdhika; // true during leap months
517
+ r.calendar.samvat.vikramSamvat; // 2081
518
+ r.calendar.samvat.shakaSamvat; // 1946
138
519
 
139
- r.masa.name; // current solar month (Mesha … Meena)
140
- r.suryaNakshatra.name; // Sun's nakshatra
141
- r.chandraRashi.name; // Moon sign
520
+ r.calendar.masa.name; // current solar month (Mesha … Meena)
521
+ r.sun.nakshatra.name; // Sun's nakshatra
522
+ r.moon.rashi.name; // Moon sign
142
523
  ```
143
524
 
144
525
  ## Sun, Moon & Muhurta
@@ -153,36 +534,51 @@ const moonset = getMoonset(localMidnightUtc, loc);
153
534
 
154
535
  // Or read off the daily result:
155
536
  const r = getDailyPanchang(date, loc, { timezone: 330 })!;
156
- r.sunrise; r.sunset; r.moonrise; r.moonset; r.nextSunrise;
157
- r.dayDurationMinutes; r.nightDurationMinutes;
537
+ r.sun.rise; r.sun.set; r.moon.rise; r.moon.set; r.sun.nextRise;
538
+ r.sun.dayDurationMinutes; r.sun.nightDurationMinutes;
158
539
 
159
540
  // Auspicious muhurtas
160
- r.brahmaMuhurta; // two muhurtas before sunrise
161
- r.abhijitMuhurta; // 8th day-muhurta; null on Wednesday (Drik convention)
162
- r.vijayaMuhurta; // 11th day-muhurta
163
- r.godhuliMuhurta; // "cow-dust" sunset muhurta
164
- r.nishitaMuhurta; // midnight muhurta (Shivaratri)
165
- r.madhyahna; // solar noon ±24 min
166
- r.pratahSandhya; // dawn twilight, ends at sunrise
167
- r.sayahnaSandhya; // dusk twilight, starts at sunset
168
- r.amritKala; // nakshatra-specific window (null when nakshatra has none)
541
+ r.muhurtas.brahma; // two muhurtas before sunrise
542
+ r.muhurtas.abhijit; // 8th day-muhurta; null on Wednesday (Drik convention)
543
+ r.muhurtas.vijaya; // 11th day-muhurta
544
+ r.muhurtas.godhuli; // "cow-dust" sunset muhurta
545
+ r.muhurtas.nishita; // midnight muhurta (Shivaratri)
546
+ r.muhurtas.madhyahna; // solar noon ±24 min
547
+ r.muhurtas.pratahSandhya; // dawn twilight, ends at sunrise
548
+ r.muhurtas.sayahnaSandhya; // dusk twilight, starts at sunset
549
+ r.muhurtas.amritKala; // nakshatra-specific window (null when nakshatra has none)
169
550
  ```
170
551
 
171
- `pratahSandhya` / `sayahnaSandhya` width = `nightDuration / 10` (~62–81 min).
552
+ `muhurtas.pratahSandhya` / `muhurtas.sayahnaSandhya` width =
553
+ `sun.nightDurationMinutes / 10` (~62–81 min).
172
554
 
173
555
  ## Inauspicious Periods
174
556
 
175
557
  ```typescript
176
558
  const r = getDailyPanchang(date, loc, { timezone: 330 })!;
177
559
 
178
- r.rahuKalam; // { start, end }
179
- r.gulikaKalam;
180
- r.yamaganda;
181
- r.durMuhurta; // two ~48-min windows
182
- r.varjyam; // { start, end } | null
183
- r.gandaMula; // { active, severity: 'mild'|'severe'|null, ... }
184
- r.bhadra; // { start, end, location: 'earth'|'heaven'|'paatal', isActive } | null
185
- r.panchaka; // boolean — Moon in last 5 nakshatras
560
+ r.inauspicious.rahuKalam; // { start, end }
561
+ r.inauspicious.gulikaKalam;
562
+ r.inauspicious.yamaganda;
563
+ r.inauspicious.durMuhurta; // two ~48-min windows
564
+ r.inauspicious.varjyam; // { start, end } | null
565
+ r.inauspicious.gandaMula; // { active, severity: 'mild'|'severe'|null, ... }
566
+ r.inauspicious.bhadra; // { start, end, location: 'earth'|'heaven'|'paatal', isActive } | null
567
+ r.inauspicious.panchaka; // boolean — Moon in last 5 nakshatras
568
+ r.inauspicious.panchakaInfo; // which of the five, and whether it's a dosha
569
+ ```
570
+
571
+ Panchaka is not one undifferentiated affliction. The tradition names five and
572
+ picks between them by **the weekday the spell began on** — so the type belongs
573
+ to the spell, not the day, and two days with identical tithi, nakshatra and
574
+ vara can carry different ones. A spell begun on a Wednesday or Thursday
575
+ (`'samanya'`) carries no named affliction at all:
576
+
577
+ ```typescript
578
+ const pk = r.inauspicious.panchakaInfo;
579
+ if (pk.active && pk.isDosha) {
580
+ console.log(pk.name, '— began on vara', pk.onsetVara); // e.g. "Mrityu Panchaka"
581
+ }
186
582
  ```
187
583
 
188
584
  ## Time-Slot Systems
@@ -191,19 +587,19 @@ r.panchaka; // boolean — Moon in last 5 nakshatras
191
587
  const r = getDailyPanchang(date, loc, { timezone: 330 })!;
192
588
 
193
589
  // Choghadiya — 8 day + 8 night named, rated slots (Amrit, Kaal, Shubh, Rog, …)
194
- r.choghadiya.day.forEach(s => console.log(s.name, s.qualityName, s.start, s.end));
590
+ r.periods.choghadiya.day.forEach(s => console.log(s.name, s.qualityName, s.start, s.end));
195
591
 
196
592
  // Gowri Panchangam ("Nalla Neram") — 8 day + 8 night Tamil slots
197
- r.gowriPanchangam.day.forEach(s => console.log(s.name, s.qualityName));
593
+ r.periods.gowri.day.forEach(s => console.log(s.name, s.qualityName));
198
594
 
199
595
  // Hora — 12 day + 12 night planetary hours (Chaldean order)
200
- r.hora.day.forEach(h => console.log(h.planet, h.start, h.end));
596
+ r.periods.hora.day.forEach(h => console.log(h.planet, h.start, h.end));
201
597
 
202
598
  // Do Ghati Muhurta — 15 day + 15 night ~48-min deity-keyed slots (no vara rotation)
203
- r.doGhatiMuhurta.day.forEach(g => console.log(g.name, g.start, g.end));
599
+ r.muhurtas.doGhati.day.forEach(g => console.log(g.name, g.start, g.end));
204
600
 
205
601
  // Panchaka Rahita — slices of the day FREE of Panchaka ([] when it pervades)
206
- r.panchakaRahita.forEach(slice => console.log(slice.start, slice.end));
602
+ r.inauspicious.panchakaRahita.forEach(slice => console.log(slice.start, slice.end));
207
603
  ```
208
604
 
209
605
  ## Special Yogas
@@ -232,12 +628,20 @@ observances (Masik Shivaratri, Pushya days, Shravan Somvar…).
232
628
 
233
629
  ```typescript
234
630
  r.festivals.forEach(f => {
631
+ // key: stable, language-independent id — 'diwali', 'makar_sankranti', …
235
632
  // type: major | minor | ekadashi | smarta_ekadashi | vaishnava_ekadashi
236
633
  // | pradosha | sankranti | eclipse
237
- console.log(f.name, f.type, f.deferralDate);
634
+ console.log(f.key, f.name, f.type, f.deferralDate);
238
635
  });
636
+
637
+ // `name` is localized, so match on `key` — never on `name`.
638
+ const hasDiwali = r.festivals.some(f => f.key === 'diwali');
239
639
  ```
240
640
 
641
+ `key` is on engine results (`getDailyPanchang`, `getInstantPanchang`,
642
+ `computeFestivalsInRange`). Entries read back out of a `buildFestivalsTable` table
643
+ carry `name` / `type` / `description` only.
644
+
241
645
  ### Regional scoping
242
646
 
243
647
  `region` scopes regional variants to one Indian state. Pan-Indian festivals
@@ -262,79 +666,60 @@ getDailyPanchang(jan13, amritsar, { timezone: 330, region: 'punjab' })!
262
666
  legacy slugs `'tamil'`, `'bengal'`, `'north-india'` are still accepted and
263
667
  mapped internally.
264
668
 
265
- ### Pre-computed table (bundled, India / IST)
266
-
267
- If you want festival *dates* without running the engine, import the static
268
- table at `panchang-ts/festivals`. It bundles a rolling **2-years-past /
269
- 5-years-future** window pre-computed against Varanasi (IST). Within India
270
- these dates are essentially universal.
271
-
272
- ```typescript
273
- import {
274
- getFestivalsForYear,
275
- getFestivalsForDate,
276
- FESTIVALS_META,
277
- FESTIVALS_YEAR_RANGE,
278
- } from 'panchang-ts/festivals';
279
-
280
- const yr = FESTIVALS_YEAR_RANGE.start; // e.g. { start: 2024, end: 2031 }
281
- getFestivalsForYear(yr)!.length; // ~150 festival days
282
- const diwali = getFestivalsForYear(yr)!
283
- .find(d => d.festivals.some(f => f.name === 'Diwali'))!.date;
284
- getFestivalsForDate(diwali); // [Narak Chaturdashi, Diwali]
285
- getFestivalsForDate(diwali, 'hi'); // [नरक चतुर्दशी, दिवाली]
286
- FESTIVALS_META.referenceLocation; // "Varanasi"
287
- FESTIVALS_META.languages; // ["en", "hi"]
288
- ```
289
-
290
- This entry point is engine-free — it ships only the JSON + accessors, so
291
- importing it won't pull the calculation engine into your bundle. Both `en`
292
- and `hi` are bundled (names *and* descriptions); pass the locale as the
293
- second argument. Eclipses are excluded here (visibility is location-dependent)
294
- — they ship as their own bundled table at `panchang-ts/eclipses` (see
295
- [Eclipses](#eclipses)).
669
+ ### Pre-computed table build your own and cache it
296
670
 
297
- ### Festivals outside India build a location table and cache it
671
+ If you want festival *dates* without running the engine in your app, compute a
672
+ table once with `buildFestivalsTable`, cache the JSON, and read it back through
673
+ the engine-free `panchang-ts/festivals` entry point.
298
674
 
299
- The bundled table is **IST-only**. Elsewhere (Europe, North America, rest
300
- of world) festival dates can shift by ±1 day, because canonical times
301
- (nishita / pradosha / chandrodaya …) are observer-dependent and the shift
302
- tracks the timezone offset, not the "region", so a single per-continent
303
- table would mis-date boundary-day festivals.
304
-
305
- For an offline app serving users worldwide, the right pattern is
306
- **compute-once-then-cache for the user's actual location**. Build a
307
- location-specific table with `buildFestivalsTable` (from the main entry —
308
- it uses the engine), persist the returned JSON, then read it back through
309
- the same accessors via their `source` argument:
675
+ **The library ships no pre-computed table.** Festival dates are
676
+ observer-dependent canonical times (nishita / pradosha / chandrodaya …) shift
677
+ with the timezone offset, so a table built for one place can be ±1 day wrong
678
+ elsewhere and any table baked into the package would also go stale. Building
679
+ your own means it is correct for *your* users and covers whatever years you
680
+ want.
310
681
 
311
682
  ```typescript
312
- import { buildFestivalsTable } from 'panchang-ts';
313
- import { getFestivalsForYear, getFestivalsForDate } from 'panchang-ts/festivals';
683
+ import { buildFestivalsTable } from 'panchang-ts'; // uses the engine
684
+ import {
685
+ readFestivalsForYear,
686
+ readFestivalsForDate,
687
+ readFestivalsYearRange,
688
+ } from 'panchang-ts/festivals'; // engine-free
314
689
 
315
- // On first use at the user's location (a few seconds on-device run it in
316
- // the background / chunk by year), then cache `table` to disk/MMKV.
690
+ // Build once at your build time, or on first launch in the background.
317
691
  const table = buildFestivalsTable({
318
- location: { latitude: 40.7128, longitude: -74.006 },
319
- timezoneOffsetMinutes: -300, // US Eastern (EST); 0 = UK, 330 = IST
692
+ location: { latitude: 25.3176, longitude: 82.9739 }, // Varanasi
693
+ timezoneOffsetMinutes: 330, // IST; -300 = US Eastern, 0 = UK
320
694
  startYear: 2024,
321
695
  endYear: 2031,
322
- languages: ['en'], // omit hi to halve the size
696
+ languages: ['en', 'hi'], // drop 'hi' to halve the size
697
+ referenceLocation: 'Varanasi',
323
698
  });
699
+ // …persist `table` as JSON (disk / MMKV / your bundler's asset pipeline).
324
700
 
325
- // Later reads are instant lookups against the cached table:
326
- getFestivalsForYear(2026, 'en', table);
327
- getFestivalsForDate('2026-11-08', 'en', table); // key is in the table's tz
701
+ // Later reads are instant lookups no engine, no ephemeris.
702
+ readFestivalsYearRange(table); // { start: 2024, end: 2031 }
703
+ readFestivalsForYear(table, 2026)!.length; // ~150 festival days
704
+ const diwali = readFestivalsForYear(table, 2026)!
705
+ .find(d => d.festivals.some(f => f.name === 'Diwali'))!.date;
706
+ readFestivalsForDate(table, diwali); // [Narak Chaturdashi, Diwali]
707
+ readFestivalsForDate(table, diwali, 'hi'); // [नरक चतुर्दशी, दिवाली]
328
708
  ```
329
709
 
330
- `buildFestivalsTable` returns the same `FestivalsFile` shape as the bundled
331
- data, so a cached table and the bundled India table are interchangeable as
332
- the `source` argument. India-majority apps can lean on the bundled table for
333
- zero first-load latency and only compute-and-cache for non-IST users.
710
+ `panchang-ts/festivals` imports no astronomy code, so a client bundle that only
711
+ *reads* a table never pulls in the engine. Keep `buildFestivalsTable` on the
712
+ build/server side (or behind a one-time on-device warm-up) and ship only the
713
+ JSON.
714
+
715
+ Eclipses are excluded here — visibility is location-dependent, so they get their
716
+ own table at `panchang-ts/eclipses` (see [Eclipses](#eclipses)).
334
717
 
335
- **Other notes:** Karva Chauth / Dhanteras / Diwali emit with Purnimanta
336
- paksha naming. To regenerate the bundled India table after a registry
337
- change, run `npm run festivals:gen` (rolling window, no constants to edit).
718
+ `npm run festivals:gen` is a worked example of the whole pattern; it writes a
719
+ rolling 2-past / 5-future window to `./festivals.json` (or a path you pass).
720
+
721
+ **Other notes:** Karva Chauth / Dhanteras / Diwali emit with Purnimanta paksha
722
+ naming.
338
723
 
339
724
  ## Eclipses
340
725
 
@@ -343,7 +728,9 @@ const r = getDailyPanchang(date, loc, { timezone: 330 })!;
343
728
  if (r.eclipse) {
344
729
  r.eclipse.kind; // 'solar' | 'lunar'
345
730
  r.eclipse.subtype; // 'partial' | 'total' | 'annular' | 'penumbral'
346
- r.eclipse.magnitude; // 0..1 fraction obscured at peak
731
+ r.eclipse.obscuration; // 0..1 fraction of the disc AREA covered
732
+ r.eclipse.magnitude; // catalogue magnitude — DIAMETER fraction;
733
+ // >1 when total, negative when penumbral
347
734
  r.eclipse.visibleFromLocation; // body above horizon at peak?
348
735
  r.eclipse.start; r.eclipse.peak; r.eclipse.end;
349
736
  r.eclipse.sutakStart; r.eclipse.sutakEnd;
@@ -354,65 +741,61 @@ import { getUpcomingSolarEclipse, getUpcomingLunarEclipse } from 'panchang-ts';
354
741
  const next = getUpcomingSolarEclipse(new Date(), loc, 365 /* days */);
355
742
  ```
356
743
 
357
- ### Pre-computed table (bundled, India / IST)
744
+ ### Pre-computed table build your own and cache it
745
+
746
+ Same pattern as festivals: build a table with `buildEclipsesTable`, cache it,
747
+ read it back through the engine-free `panchang-ts/eclipses` entry point.
358
748
 
359
- Like the festivals table, eclipse data ships as a static, engine-free entry
360
- at `panchang-ts/eclipses` — a rolling **2-years-past / 5-years-future** window
361
- pre-computed against Varanasi (IST). It lists every eclipse **visible from
362
- there during any phase** (so an eclipse already in progress at moon/sunrise or
363
- moon/sunset is included); the `visibleAtPeak` flag tells you whether greatest
364
- eclipse itself is observable. Within India visibility is essentially uniform.
749
+ **No table is bundled.** Which eclipses are visible and therefore which carry
750
+ `sutak` — is location-dependent, so a table is only meaningful for the place it
751
+ was built for.
365
752
 
366
753
  ```typescript
754
+ import { buildEclipsesTable } from 'panchang-ts'; // uses the engine
367
755
  import {
368
- getEclipsesForYear,
369
- getEclipsesForDate,
370
- ECLIPSES_META,
371
- ECLIPSES_YEAR_RANGE,
372
- } from 'panchang-ts/eclipses';
756
+ readEclipsesForYear,
757
+ readEclipsesForDate,
758
+ readEclipsesYearRange,
759
+ } from 'panchang-ts/eclipses'; // engine-free
760
+
761
+ const table = buildEclipsesTable({
762
+ location: { latitude: 25.3176, longitude: 82.9739 }, // Varanasi
763
+ timezoneOffsetMinutes: 330,
764
+ startYear: 2024,
765
+ endYear: 2031,
766
+ languages: ['en', 'hi'],
767
+ // visibleOnly: false → also include eclipses below the horizon (no sutak)
768
+ });
769
+ // …persist `table` as JSON, then:
373
770
 
374
- const e = getEclipsesForYear(2025)![0].eclipses[0];
771
+ readEclipsesYearRange(table); // { start: 2024, end: 2031 }
772
+ const e = readEclipsesForYear(table, 2025)![0].eclipses[0];
375
773
  e.kind; // 'lunar'
376
774
  e.subtype; // 'total'
377
775
  e.start; e.peak; e.end; // ISO UTC strings
378
- e.magnitude; // 0..1 obscuration at peak
379
- e.visibleFromLocation; // visible during any phase? (always true in bundled table)
776
+ e.obscuration; // 0..1 disc area covered at peak
777
+ e.magnitude; // catalogue magnitude (diameter); >1 total, <0 penumbral
778
+ e.visibleFromLocation; // visible during any phase?
380
779
  e.visibleAtPeak; // is greatest eclipse itself above the horizon?
381
780
  e.sutak; // { start, end } — see note below
382
- getEclipsesForDate('2025-09-07', 'hi'); // [पूर्ण चंद्र ग्रहण]
383
- ECLIPSES_META.referenceLocation; // "Varanasi"
781
+ readEclipsesForDate(table, '2025-09-07', 'hi'); // [पूर्ण चंद्र ग्रहण]
384
782
  ```
385
783
 
386
- Each entry carries `en` + `hi` text. Solar eclipses report the subtype seen
387
- **locally** (a globally-total eclipse may read `partial` from Varanasi). The
388
- `sutak` window is present only where it applies — all visible solar eclipses
389
- and visible **umbral** (partial/total) lunar eclipses; **penumbral** lunar
390
- eclipses carry no `sutak` and are not religiously observed (drik / pandit
391
- consensus). For full astronomical detail (e.g. eclipses *not* visible in
392
- India), use `getUpcomingEclipses` / `getEclipsesInRange` from the main entry.
784
+ By default a table lists every eclipse **visible from the location during any
785
+ phase** (so one already in progress at moon/sunrise or moon/sunset is included);
786
+ `visibleAtPeak` tells you whether greatest eclipse itself is observable.
393
787
 
394
- **Outside India:** which eclipses are visible and thus carry `sutak` —
395
- differs by location. Build and cache a location-specific table with
396
- `buildEclipsesTable` (main entry, uses the engine), then read it back via the
397
- same accessors' `source` argument the same compute-once-then-cache pattern
398
- as festivals:
788
+ Solar eclipses report the subtype seen **locally** (a globally-total eclipse may
789
+ read `partial` from a given place). The `sutak` window is present only where it
790
+ applies all visible solar eclipses and visible **umbral** (partial/total)
791
+ lunar eclipses; **penumbral** lunar eclipses carry no `sutak` and are not
792
+ religiously observed (drik / pandit consensus).
399
793
 
400
- ```typescript
401
- import { buildEclipsesTable } from 'panchang-ts';
402
- import { getEclipsesForYear } from 'panchang-ts/eclipses';
794
+ For one-off astronomical detail without building a table, use
795
+ `getUpcomingEclipses` / `computeEclipsesInRange` from the main entry.
403
796
 
404
- const table = buildEclipsesTable({
405
- location: { latitude: 51.5074, longitude: -0.1278 },
406
- timezoneOffsetMinutes: 0, // UK / GMT
407
- startYear: 2024,
408
- endYear: 2031,
409
- // visibleOnly: false → also include eclipses below the horizon (no sutak)
410
- });
411
- getEclipsesForYear(2025, 'en', table);
412
- ```
413
-
414
- To regenerate the bundled India table, run `npm run eclipses:gen` (rolling
415
- window, no constants to edit).
797
+ `npm run eclipses:gen` is a worked example; it writes a rolling 2-past /
798
+ 5-future window to `./eclipses.json` (or a path you pass).
416
799
 
417
800
  ## Moon Phases
418
801
 
@@ -422,48 +805,44 @@ astronomical quarter moments, distinct from the same-named *tithis*, which are
422
805
  ~24h windows.)
423
806
 
424
807
  ```typescript
425
- import { getMoonPhasesInRange } from 'panchang-ts';
426
- const phases = getMoonPhasesInRange(new Date('2026-01-01'), new Date('2026-12-31'));
808
+ import { computeMoonPhasesInRange } from 'panchang-ts';
809
+ const phases = computeMoonPhasesInRange(new Date('2026-01-01'), new Date('2026-12-31'));
427
810
  phases.forEach(p => console.log(p.phase, p.time.toISOString())); // ~49 / year
428
811
  ```
429
812
 
430
- ### Pre-computed table (bundled, India / IST)
813
+ ### Pre-computed table build your own and cache it
431
814
 
432
- Same engine-free pattern as festivals and eclipses, at `panchang-ts/moon-phases`
433
- a rolling **2-years-past / 5-years-future** window. Phases are global
434
- instants; the bundled table maps each onto its **IST** calendar date (so a new
435
- moon at 19:52 UTC on Jan 18 is listed under Jan 19 in India).
815
+ Same pattern again, at `panchang-ts/moon-phases`. Phases are **global instants**,
816
+ so `buildMoonPhasesTable` takes only a `timezoneOffsetMinutes` (no coordinates)
817
+ the timezone just decides which calendar date each instant lands on (a new
818
+ moon at 19:52 UTC on Jan 18 is listed under Jan 19 in IST).
436
819
 
437
820
  ```typescript
821
+ import { buildMoonPhasesTable } from 'panchang-ts'; // uses the engine
438
822
  import {
439
- getMoonPhasesForYear,
440
- getMoonPhasesForDate,
441
- MOON_PHASES_META,
442
- MOON_PHASES_YEAR_RANGE,
443
- } from 'panchang-ts/moon-phases';
444
-
445
- getMoonPhasesForYear(2026)!.length; // ~49 phase days
446
- getMoonPhasesForDate('2026-01-03'); // [{ phase: 'full', name: 'Full Moon', ... }]
447
- getMoonPhasesForDate('2026-01-03', 'hi'); // [{ phase: 'full', name: 'पूर्णिमा', ... }]
448
- ```
449
-
450
- Each entry carries `phase`, the phase `time` (ISO UTC), and `en` + `hi` text.
451
- For another timezone, build and cache a table with `buildMoonPhasesTable` (main
452
- entry) and pass it as the accessors' `source` argument — it takes only a
453
- `timezoneOffsetMinutes` (no coordinates, since phases are location-independent):
454
-
455
- ```typescript
456
- import { buildMoonPhasesTable } from 'panchang-ts';
457
- import { getMoonPhasesForYear } from 'panchang-ts/moon-phases';
823
+ readMoonPhasesForYear,
824
+ readMoonPhasesForDate,
825
+ readMoonPhasesYearRange,
826
+ } from 'panchang-ts/moon-phases'; // engine-free
458
827
 
459
828
  const table = buildMoonPhasesTable({
460
- timezoneOffsetMinutes: -300, // US Eastern
461
- startYear: 2024, endYear: 2031,
829
+ timezoneOffsetMinutes: 330, // IST; -300 = US Eastern
830
+ startYear: 2024,
831
+ endYear: 2031,
832
+ languages: ['en', 'hi'],
462
833
  });
463
- getMoonPhasesForYear(2026, 'en', table);
834
+ // …persist `table` as JSON, then:
835
+
836
+ readMoonPhasesYearRange(table); // { start: 2024, end: 2031 }
837
+ readMoonPhasesForYear(table, 2026)!.length; // ~49 phase days
838
+ readMoonPhasesForDate(table, '2026-01-03'); // [{ phase: 'full', name: 'Full Moon', … }]
839
+ readMoonPhasesForDate(table, '2026-01-03', 'hi'); // [{ phase: 'full', name: 'पूर्णिमा', … }]
464
840
  ```
465
841
 
466
- Regenerate the bundled India table with `npm run moon-phases:gen`.
842
+ Each entry carries `phase`, the phase `time` (ISO UTC), and `en` + `hi` text.
843
+
844
+ `npm run moon-phases:gen` is a worked example; it writes a rolling 2-past /
845
+ 5-future window to `./moonPhases.json` (or a path you pass).
467
846
 
468
847
  ## Planetary Positions
469
848
 
@@ -526,8 +905,8 @@ const r = getDailyPanchang(date, loc, {
526
905
  janmaRashi: 3, // 0 = Mesha … 11 = Meena
527
906
  janmaNakshatra: 0, // 0 = Ashwini … 26 = Revati
528
907
  })!;
529
- r.chandraBalam!; // { house, quality: 'strong'|'weak', name, englishName }
530
- r.tarabala!; // { taraIndex, name, englishName, quality }
908
+ r.chandraBalam; // { house, quality: 'strong'|'weak', name, englishName } — null without janmaRashi
909
+ r.tarabala; // { taraIndex, name, englishName, quality } — null without janmaNakshatra
531
910
 
532
911
  import { computeSadeSati } from 'panchang-ts';
533
912
  const ss = computeSadeSati(natalMoonRashiIndex, new Date());
@@ -554,6 +933,12 @@ const lagna = computeLagna(birth, loc, 'lahiri', 'en');
554
933
  // Placidus-KP throws PanchangError('CIRCUMPOLAR') beyond ±66.5°.
555
934
  const houses = computeBhava(birth, loc, { houseSystem: 'whole-sign' });
556
935
 
936
+ // `chart.planets` is the ordered list; `chart.byPlanet` is the same nine
937
+ // placements keyed by graha, for direct lookup without a linear scan.
938
+ const chart = computeRashiChart(birth, loc);
939
+ chart.byPlanet.Mars.house; // instead of chart.planets.find(...)!
940
+ chart.planets.map(p => p.rashi); // iterate the list as before
941
+
557
942
  // D1 — full Rashi chart with 9-graha house placement.
558
943
  const d1 = computeRashiChart(birth, loc, { houseSystem: 'whole-sign' });
559
944
  d1.planets.find(p => p.planet === 'Jupiter')?.house;
@@ -598,6 +983,14 @@ const richer = computeAshtakoot(
598
983
  { rashi: 0, nakshatra: 1, lagnaRashi: 1, navamsaRashi: 5 },
599
984
  );
600
985
 
986
+ // Manglik is a PAIRWISE verdict, not a per-chart one: when both partners are
987
+ // Manglik the two afflictions neutralise each other, so the pair is clean
988
+ // where a Manglik/non-Manglik pair is not.
989
+ const m = computeMangalCompatibility(boyChart, girlChart);
990
+ m.afflicted; // false when neither is Manglik AND when both are
991
+ m.cancellations; // ['both natives Manglik — mutual cancellation']
992
+ m.boy; m.girl; // each native's own MangalDoshaInfo, severity included
993
+
601
994
  // Pathu Porutham (Tamil/Kerala, 10-fold) — binary pass/fail per koot.
602
995
  // Three vetoes (Yoni, Rajju, Vedha) flip `recommended` regardless of count.
603
996
  const tp = computePathuPorutham(
@@ -802,12 +1195,17 @@ to `computePrashnaChart` for traditional Vedic Prashna.
802
1195
  ## Muhurta Engine
803
1196
 
804
1197
  ```typescript
805
- import { scoreMuhurta, findAuspiciousDates, vivahRule } from 'panchang-ts';
1198
+ import { scoreMuhurta, computeAuspiciousDatesInRange, vivahRule } from 'panchang-ts';
806
1199
 
807
1200
  const r = scoreMuhurta(new Date('2026-05-12'), DELHI, vivahRule, { timezone: 330 });
808
- // → { date, score: 0..100, passes: boolean, reasons: string[] }
1201
+ // → { date, score: 0..100, passes: boolean,
1202
+ // reasons: string[], // diagnostic English
1203
+ // factors: MuhurtaFactor[] } // { code, axis, index?, delta } — stable
1204
+
1205
+ r.factors.filter(f => f.delta < 0); // what cost the day points
1206
+ r.factors.some(f => f.axis === 'exclusion'); // hard-excluded?
809
1207
 
810
- const dates = findAuspiciousDates(
1208
+ const dates = computeAuspiciousDatesInRange(
811
1209
  vivahRule,
812
1210
  new Date('2026-05-01'),
813
1211
  new Date('2026-05-31'),
@@ -820,29 +1218,106 @@ const myRule: MuhurtaRule = {
820
1218
  occasion: 'launch_party',
821
1219
  auspiciousVaras: [3, 4, 5],
822
1220
  auspiciousNakshatras: [11, 12, 21],
823
- excludeBhadra: true,
1221
+ bhadra: 'penalize', // 'ignore' | 'penalize' | 'exclude'
824
1222
  excludeEkadashi: true,
825
1223
  excludeAdhikaMasa: true,
826
1224
  };
827
1225
  ```
828
1226
 
1227
+ **Tithi and vara are scored jointly, not just per-anga.** The classical Vara ×
1228
+ Tithi yogas — Siddha, Amrita, Dagdha, Visha, Hutasana, Krakacha, Samvartaka —
1229
+ are applied to every rule, so a Rikta tithi landing on a Saturday is partly
1230
+ redeemed by Siddha yoga rather than flatly penalised. Set
1231
+ `varaTithiYogas: false` for the older per-anga-only scoring, or call
1232
+ `computeVaraTithiYogas(vara, tithi)` directly. Where an auspicious and an
1233
+ inauspicious yoga both fire — a documented ambiguity in the sources — both are
1234
+ surfaced as separate factors and allowed to net out.
1235
+
1236
+ `bhadra` defaults to `'ignore'`; the stock rules use `'penalize'`. A whole-day
1237
+ `'exclude'` is rarely what you want: Vishti karana sits at fixed positions in
1238
+ the tithi cycle, so vetoing the day removes seven tithis outright — among them
1239
+ Shukla Ekadashi, which the same sources list as *preferred* for vivah. Read
1240
+ `panchang.inauspicious.bhadra` for the window and schedule around it.
1241
+ `excludeBhadra: true` still works as an alias for `bhadra: 'exclude'`.
1242
+
829
1243
  13 stock rules: vivah, griha pravesh, namakarana, vidyarambh, vahan kharidi,
830
1244
  annaprashan, mundan, upanayanam, karnavedha, aksharabhyasam, seemantham, shop
831
1245
  opening, travel start.
832
1246
 
1247
+ ### Pre-computed table — build your own and cache it
1248
+
1249
+ Scoring a year of days runs the engine ~365 times. If your app asks the same
1250
+ question repeatedly, compute the answer once and ship the JSON — the same
1251
+ pattern the festival, eclipse and Moon-phase tables use.
1252
+
1253
+ ```typescript
1254
+ import { buildMuhurtaTable, vivahRule } from 'panchang-ts';
1255
+
1256
+ const table = buildMuhurtaTable({
1257
+ rule: vivahRule,
1258
+ location: { latitude: 25.3176, longitude: 82.9739 },
1259
+ timezoneOffsetMinutes: 330,
1260
+ startYear: 2026,
1261
+ endYear: 2031,
1262
+ referenceLocation: 'Varanasi',
1263
+ });
1264
+ // persist JSON.stringify(table) — 6 years of vivah dates is ~74 KB
1265
+ ```
1266
+
1267
+ Read it back through the engine-free `panchang-ts/muhurta` entry (~1.7 KB, no
1268
+ astronomy code in your bundle):
1269
+
1270
+ ```typescript
1271
+ import {
1272
+ readMuhurtaForYear,
1273
+ readMuhurtaForDate,
1274
+ readMuhurtaYearRange,
1275
+ readMuhurtaOccasion,
1276
+ readBestMuhurtaDays,
1277
+ } from 'panchang-ts/muhurta';
1278
+
1279
+ const table = JSON.parse(await (await fetch('/muhurta-vivah.json')).text());
1280
+
1281
+ readMuhurtaOccasion(table); // 'vivah'
1282
+ readMuhurtaYearRange(table); // { start: 2026, end: 2031 }
1283
+ readMuhurtaForYear(table, 2026); // MuhurtaTableDay[] — passing days, by date
1284
+ readMuhurtaForDate(table, '2026-11-11');
1285
+ readBestMuhurtaDays(table, 5); // top 5 across the table, highest first
1286
+ ```
1287
+
1288
+ Only days that **pass** the rule are stored by default; pass
1289
+ `includeFailures: true` to keep every day with its score. Scores are location-
1290
+ *and* rule-dependent, so a table built for Varanasi and `vivah` says nothing
1291
+ about another place or occasion.
1292
+
1293
+ `npm run muhurta:gen` is a worked example script
1294
+ ([scripts/generate-muhurta-json.ts](scripts/generate-muhurta-json.ts)):
1295
+
1296
+ ```bash
1297
+ npm run muhurta:gen -- muhurta-vivah.json vivah
1298
+ ```
1299
+
833
1300
  Scoring: starts at 50; +10 per matching auspicious axis (tithi / nakshatra /
834
1301
  vara / yoga), -15 per inauspicious axis, hard exclusions zero the score.
835
1302
  Special yogas (Amrit Siddhi, Sarvartha Siddhi, Ravi/Guru Pushya) add +5;
836
1303
  Jwalamukhi subtracts -10. Clamped 0..100; `passes: true` when score ≥ 50.
837
1304
 
1305
+ Every scoring input appears in both `reasons` (English prose, diagnostic, not a
1306
+ stable format) and `factors` (structured, with a stable `code`). Localize and
1307
+ filter on `factors`.
1308
+
1309
+ `scoreMuhurta` computes only the sections it actually scores against, so it is
1310
+ cheaper than a full `getDailyPanchang`. `computeAuspiciousDatesInRange` does not narrow —
1311
+ each returned day carries its complete `panchang` for callers to drill into.
1312
+
838
1313
  ## Calendar Conversion
839
1314
 
840
1315
  ```typescript
841
1316
  import {
842
1317
  convertGregorianToHindu, convertHinduToGregorian,
843
1318
  getKaliYugaYear, getHinduNewYear,
844
- getEkadashiDatesForYear, getSankrantisForYear,
845
- getFestivalsInRange, getUpcomingEclipses, getEclipsesInRange,
1319
+ computeEkadashiDatesForYear, computeSankrantisForYear,
1320
+ computeFestivalsInRange, getUpcomingEclipses, computeEclipsesInRange,
846
1321
  } from 'panchang-ts';
847
1322
 
848
1323
  // Gregorian → Hindu coords at sunrise
@@ -857,9 +1332,9 @@ const dates = convertHinduToGregorian(
857
1332
  getKaliYugaYear(new Date('2026-04-01')); // 5127
858
1333
  getHinduNewYear(2026, 'tamil-nadu', DELHI, { timezone: 330 }); // Puthandu
859
1334
 
860
- getEkadashiDatesForYear(2026, DELHI, { timezone: 330 }); // ~24 Date[]
861
- getSankrantisForYear(2026, DELHI, { timezone: 330 }); // 12 SankrantiEvent[]
862
- getFestivalsInRange(start, end, DELHI, { timezone: 330 }); // FestivalDay[]
1335
+ computeEkadashiDatesForYear(2026, DELHI, { timezone: 330 }); // ~24 Date[]
1336
+ computeSankrantisForYear(2026, DELHI, { timezone: 330 }); // 12 SankrantiEvent[]
1337
+ computeFestivalsInRange(start, end, DELHI, { timezone: 330 }); // FestivalDay[]
863
1338
  getUpcomingEclipses(new Date(), DELHI, 5);
864
1339
  ```
865
1340
 
@@ -872,11 +1347,11 @@ tithi (e.g. Ugadi 2026), falls back to the Amanta-Chaitra-masa boundary.
872
1347
 
873
1348
  ```typescript
874
1349
  const hi = getDailyPanchang(date, loc, { timezone: 330, language: 'hi' })!;
875
- hi.tithis[0].name; // "कृष्ण चतुर्दशी"
876
- hi.vara.name; // "मंगलवार"
877
- hi.chandramasa.name; // "माघ"
878
- hi.choghadiya.day[0].name; // "अमृत"
879
- hi.vara.englishName; // "Tuesday" — englishName always English
1350
+ hi.angas.tithis[0].name; // "कृष्ण चतुर्दशी"
1351
+ hi.angas.vara.name; // "मंगलवार"
1352
+ hi.calendar.chandramasa.name; // "माघ"
1353
+ hi.periods.choghadiya.day[0].name; // "अमृत"
1354
+ hi.angas.vara.englishName; // "Tuesday" — englishName always English
880
1355
 
881
1356
  // All options:
882
1357
  const r = getDailyPanchang(date, loc, {
@@ -885,10 +1360,10 @@ const r = getDailyPanchang(date, loc, {
885
1360
  language: 'en', // en | hi
886
1361
  masaSystem: 'purnimanta', // purnimanta | amanta
887
1362
  region: 'all', // 21 state slugs + 'nepal' + 'all'
888
- computeEndTimes: true, // false → ~5x speedup, names only
889
- precision: 'standard', // standard (15 iter) | high (25 iter)
890
- janmaRashi: undefined, // pass to add r.chandraBalam
891
- janmaNakshatra: undefined, // pass to add r.tarabala
1363
+ computeEndTimes: true, // false → skip transition searches
1364
+ sections: undefined, // undefined = all; see Performance
1365
+ janmaRashi: undefined, // pass to populate r.chandraBalam (else null)
1366
+ janmaNakshatra: undefined, // pass to populate r.tarabala (else null)
892
1367
  });
893
1368
  ```
894
1369
 
@@ -899,6 +1374,21 @@ IANA zones.
899
1374
 
900
1375
  ---
901
1376
 
1377
+ ### Localized vs machine-readable fields
1378
+
1379
+ Every user-facing string follows `language`. Where a value is also meaningful to
1380
+ code, the two are separate fields — the stable key never changes with language:
1381
+
1382
+ | Machine-readable | Localized display |
1383
+ |---|---|
1384
+ | `festival.key` (`'diwali'`) | `festival.name` (`"दिवाली"`) |
1385
+ | `bhadra.location` (`'paatal'`) | `bhadra.locationName` (`"पाताल"`) |
1386
+ | `eclipse.kind` / `eclipse.subtype` | `eclipse.description` |
1387
+ | `muhurtaScore.factors[].code` | `muhurtaScore.reasons` (English only) |
1388
+
1389
+ `MuhurtaScore.reasons` is diagnostic English and not a stable format; use
1390
+ `factors` for anything shown to a user or branched on in code.
1391
+
902
1392
  ## Types & Exports
903
1393
 
904
1394
  <details>
@@ -927,7 +1417,8 @@ interface VaraInfo {
927
1417
  }
928
1418
 
929
1419
  interface FestivalInfo {
930
- name: string;
1420
+ key: string; // stable, language-independent id — match on this
1421
+ name: string; // localized — display only
931
1422
  type: 'major' | 'minor' | 'ekadashi' | 'smarta_ekadashi' | 'vaishnava_ekadashi'
932
1423
  | 'pradosha' | 'sankranti' | 'eclipse';
933
1424
  description?: string;
@@ -958,7 +1449,11 @@ interface EclipseInfo {
958
1449
  subtype: 'partial' | 'total' | 'annular' | 'penumbral';
959
1450
  start: Date; peak: Date; end: Date;
960
1451
  visibleFromLocation: boolean;
961
- magnitude: number; // [0, 1] at peak
1452
+ obscuration: number; // disc AREA covered at peak, [0, 1]
1453
+ magnitude: number; // catalogue magnitude — disc DIAMETER covered.
1454
+ // Not [0, 1]: >1 for a total eclipse, negative
1455
+ // for a penumbral lunar one (the Moon misses
1456
+ // the umbra), exactly as NASA's canon prints it.
962
1457
  sutakStart: Date; // 12 h pre-solar / 9 h pre-lunar
963
1458
  sutakEnd: Date;
964
1459
  description: string;
@@ -967,6 +1462,7 @@ interface EclipseInfo {
967
1462
  interface BhadraInfo {
968
1463
  start: Date; end: Date;
969
1464
  location: 'earth' | 'heaven' | 'paatal'; // 'earth' = malefic for all work
1465
+ locationName: string; // localized display name
970
1466
  isActive: boolean;
971
1467
  }
972
1468
  ```
@@ -1033,6 +1529,7 @@ getSiderealSunLongitude, getSiderealMoonLongitude, getAyanamsa
1033
1529
  computeRahuKalam, computeGulikaKalam, computeYamaganda
1034
1530
  computeVarjyam, computeGandaMula, computeAnandadiYoga
1035
1531
  computePanchakaRahita, computeDoGhati, computeGowriPanchangam
1532
+ computePanchaka, classifyPanchaka, isPanchakaDosha, findPanchakaOnset
1036
1533
  computeAbhijitMuhurta, computeBrahmaMuhurta, computeVijayaMuhurta
1037
1534
  computeGodhuliMuhurta, computeNishitaMuhurta, computeAmritKala
1038
1535
  computeMadhyahna, computePratahSandhya, computeSayahnaSandhya
@@ -1042,7 +1539,7 @@ getUpcomingSolarEclipse, getUpcomingLunarEclipse, getEclipseDuringDay
1042
1539
  isEclipseVisibleAnyPhase
1043
1540
 
1044
1541
  // Moon phases (new / quarters / full as precise instants)
1045
- getMoonPhasesInRange
1542
+ computeMoonPhasesInRange
1046
1543
 
1047
1544
  // Jyotish — planets, dashas, transits
1048
1545
  computePlanetaryPositions, GRAHA_ABBR
@@ -1062,14 +1559,15 @@ computeVarshaphala, computeTithiPravesha, computeArudhas, computeUpagrahas, comp
1062
1559
 
1063
1560
  // Jyotish — compatibility, doshas
1064
1561
  computeAshtakoot, computePathuPorutham
1065
- computeMangalDosha, computeKaalSarp, computePitruDosha
1562
+ computeMangalDosha, computeMangalCompatibility, computeKaalSarp, computePitruDosha
1066
1563
 
1067
1564
  // KP / Prashna
1068
1565
  computeKpSubLord, computeKpCuspalSubLords, computeKpSignificators
1069
1566
  computePrashnaChart
1070
1567
 
1071
1568
  // Muhurta engine
1072
- scoreMuhurta, findAuspiciousDates, STOCK_MUHURTA_RULES
1569
+ scoreMuhurta, computeAuspiciousDatesInRange, STOCK_MUHURTA_RULES
1570
+ computeVaraTithiYogas
1073
1571
  vivahRule, grihaPraveshRule, namakaranaRule, vidyarambhRule, vahanKharidiRule
1074
1572
  annaprashanRule, mundanRule, upanayanamRule, karnavedhaRule
1075
1573
  aksharabhyasamRule, seemanthamRule, shopOpeningRule, travelStartRule
@@ -1077,11 +1575,12 @@ aksharabhyasamRule, seemanthamRule, shopOpeningRule, travelStartRule
1077
1575
  // Calendar conversion + yearly listings
1078
1576
  convertGregorianToHindu, convertHinduToGregorian
1079
1577
  getKaliYugaYear, getHinduNewYear, computeSamvat
1080
- getEkadashiDatesForYear, getSankrantisForYear, getFestivalsInRange
1081
- getUpcomingEclipses, getEclipsesInRange
1578
+ computeEkadashiDatesForYear, computeSankrantisForYear, computeFestivalsInRange
1579
+ getUpcomingEclipses, computeEclipsesInRange
1082
1580
 
1083
- // Static data tables (engine-using runtime builders; bundled JSON at
1084
- // panchang-ts/festivals, panchang-ts/eclipses, panchang-ts/moon-phases)
1581
+ // Static data tables build one, cache the JSON, then read it back through
1582
+ // the engine-free panchang-ts/festivals · /eclipses · /moon-phases entries.
1583
+ // No table ships with the package.
1085
1584
  buildFestivalsTable, buildEclipsesTable, buildMoonPhasesTable
1086
1585
 
1087
1586
  // Errors
@@ -1103,14 +1602,18 @@ Two-pass rendering pattern for smooth UI:
1103
1602
  import { getDailyPanchang } from 'panchang-ts';
1104
1603
  import { InteractionManager } from 'react-native';
1105
1604
 
1106
- // Pass 1 — instant, names only (~0.1 ms Node, <100 ms Hermes)
1605
+ // Pass 1 — cheapest useful result: elements, slots, muhurtas
1606
+ // (~0.25 ms Node on a new date, ~0.14 ms on one already seen).
1607
+ // `sections` is the lever; `computeEndTimes: false` only helps once it is
1608
+ // narrowed, and slightly hurts on a full-section call.
1107
1609
  const fast = getDailyPanchang(date, location, {
1108
1610
  timezone: 330,
1611
+ sections: [],
1109
1612
  computeEndTimes: false,
1110
1613
  });
1111
1614
  setState(fast);
1112
1615
 
1113
- // Pass 2 — background, full with end-times (~0.5 ms Node, <500 ms Hermes)
1616
+ // Pass 2 — background, everything (~0.41 ms Node)
1114
1617
  InteractionManager.runAfterInteractions(() => {
1115
1618
  setState(getDailyPanchang(date, location, { timezone: 330 }));
1116
1619
  });
@@ -1120,7 +1623,7 @@ InteractionManager.runAfterInteractions(() => {
1120
1623
 
1121
1624
  ## Accuracy
1122
1625
 
1123
- 8,164 tests across 100 files, including fixtures cross-verified against reference
1626
+ 8,368 tests across 121 files, including fixtures cross-verified against reference
1124
1627
  panchang calculations spanning 2025–2026 across 10 Indian cities plus New York,
1125
1628
  London, Sydney, Dubai, Singapore (diaspora fixtures cover DST on
1126
1629
  `America/New_York`).
@@ -1130,8 +1633,8 @@ London, Sydney, Dubai, Singapore (diaspora fixtures cover DST on
1130
1633
  | Sunrise / Sunset | ≤29 s observed vs reference minute-midpoint (±45 s tolerance) |
1131
1634
  | Moonrise / Moonset | Meeus apparent-upper-limb (refraction + parallax); ~3–5 min vs simpler-horizon authorities is expected |
1132
1635
  | Tithi / Nakshatra / Yoga / Karana names | Exact match vs reference |
1133
- | Tithi / Nakshatra / Yoga / Karana end-times | ±3 min tolerance, max 2.01 min observed |
1134
- | Ayanamsa | ±0.005° vs Swiss Ephemeris |
1636
+ | Tithi / Nakshatra / Yoga / Karana end-times | ≤60 s vs Drik across all 20 audited comparisons (tithi 46 s, karana 51 s, nakshatra 24 s, yoga 60 s) |
1637
+ | Ayanamsa (Lahiri) | Reproduces DrikPanchang's published value to ~0.01″ across 1950–2050 |
1135
1638
  | Planetary positions (Sun–Saturn) | ±0.02° sidereal |
1136
1639
  | Planetary positions (Rahu/Ketu, mean node) | ≤0.5° typical; ±2° tolerance |
1137
1640
  | Planetary positions (Rahu/Ketu, true node) | ≤0.6° typical (Meeus periodic correction) |
@@ -1140,6 +1643,18 @@ London, Sydney, Dubai, Singapore (diaspora fixtures cover DST on
1140
1643
  | Ashtakoot total | ±1 point per pair across 30+ matched pairs |
1141
1644
  | Sade Sati arc start/end | ±1–2 days vs authoritative ephemerides |
1142
1645
 
1646
+ **End-time drift.** Drik publishes end times to the minute, so each comparison
1647
+ above carries ±30 s of quantization — that, not the search, dominates what is
1648
+ left. Two independent checks bound the library's own contribution: the reported
1649
+ value matches an exact bisection of the same index function to ≤24 ms, and Sun
1650
+ and Moon agree with Drik's sidereal positions to well under an arcsecond
1651
+ (`tests/validation/element-endtime-audit.test.ts` carries the working).
1652
+
1653
+ **Ayanamsa.** Only Lahiri is verified against an external reference — Drik
1654
+ publishes no value for the other four. Raman, KP, True Chitrapaksha and
1655
+ Thirukanitham are held at their historical offsets from Lahiri, so correcting
1656
+ Lahiri carried them along rather than silently changing how each relates to it.
1657
+
1143
1658
  **Detection notes.** **Aadal / Vidaal** follow the classical Moon-from-Sun
1144
1659
  nakshatra-distance rule (AstroShastra, HoraSarvam, Ernst Wilhelm), NOT the
1145
1660
  Tamil-Vakya weekday rule used by some online panchangs. **Varjyam** emits the
@@ -1166,13 +1681,160 @@ Jayanti — matches the canonical date across 2025 and 2026 fixtures.
1166
1681
 
1167
1682
  ## Performance
1168
1683
 
1169
- | Mode | Node.js | Hermes (budget Android) |
1170
- |---|---|---|
1171
- | Names-only (`computeEndTimes: false`) | ~0.1 ms | <100 ms |
1172
- | Full with end-times | ~0.5 ms | <500 ms |
1684
+ Measured at Pune on an Apple M-series laptop under Node 24, median of 11
1685
+ processes per configuration. Treat them as relative guidance, not a spec — they
1686
+ move with hardware, latitude and date.
1687
+
1688
+ Two columns, because they differ and both are real. **Distinct days** is the
1689
+ calendar-scan cost: every call misses the solar rise/set cache. **Same day
1690
+ repeated** is what a UI that re-renders one date sees, and what `npm run bench`
1691
+ reports. The last column is the **published 4.3.1 package**, installed from npm
1692
+ and benchmarked beside this one.
1693
+
1694
+ | `getDailyPanchang` call | Distinct days | Same day repeated | 4.3.1 (distinct) |
1695
+ |---|---|---|---|
1696
+ | Default (all sections + end-times) | **~0.41 ms** | **~0.17 ms** | ~6.06 ms |
1697
+ | `computeEndTimes: false` | ~0.39 ms | ~0.15 ms | ~5.63 ms |
1698
+ | Without `'festivals'` | ~0.39 ms | — | n/a |
1699
+ | `sections: ['festivals', 'eclipse']` | ~0.40 ms | — | n/a |
1700
+ | `sections: []` | ~0.27 ms | — | n/a |
1701
+ | `sections: []` + `computeEndTimes: false` | ~0.25 ms | ~0.14 ms | n/a |
1702
+ | `getInstantPanchang` | ~0.21 ms | ~0.10 ms | ~0.43 ms |
1703
+
1704
+ `sections` did not exist before v5, so the rows using it have no 4.3.1
1705
+ counterpart — passing it to 4.3.1 is silently ignored and you get a full run.
1706
+
1707
+ **A default day is ~15× cheaper than in 4.3.1**, and a repeated day ~37×. Most
1708
+ of that is not tuning: 4.3.1 ran `astronomy-engine`'s full lunar theory inside
1709
+ the eclipse search on every day containing a syzygy, and had no cache that
1710
+ survived a call.
1711
+
1712
+ One surface moved the other way, and it is deliberate: the **raw longitude
1713
+ getters** (`getSiderealSunLongitude`, `getSiderealMoonLongitude`) cost about
1714
+ twice what they did in 4.3.1 per call — ~14 µs against ~7 µs for the Moon,
1715
+ ~2.8 µs against ~1.4 µs for the Sun — because the own-ephemeris series keep
1716
+ roughly three times `astronomy-engine`'s accuracy against JPL DE441, and the
1717
+ evaluation is sine-bound, so more terms cost proportionally more. Every
1718
+ documented workflow (`getDailyPanchang`, `getInstantPanchang`, the year/range
1719
+ APIs, the static tables) amortizes those reads through caches and is faster
1720
+ than 4.3.1 by the factors above; the per-call price is only visible to code
1721
+ calling the raw getters in a tight loop over distinct instants. For scanning
1722
+ workloads, prefer the range APIs or `getDailyPanchang` — they read through
1723
+ interpolated longitude blocks precisely so that this cost is paid once per day
1724
+ rather than once per read.
1725
+
1726
+ The same trade surfaces once more in the birth-chart *primitives*:
1727
+ `computeRashiChart` and `computeNavamsa` measure ~0.31 ms against ~0.09 ms on
1728
+ published 4.3.1 — a chart is fifteen full-accuracy planet evaluations (three
1729
+ per planet, for the retrograde probes) and nothing amortizes them. The deeper
1730
+ chart stack inverts it again: `computeShadbala` and `computeBhavaBala` come
1731
+ out ~2× *faster* than 4.3.1, because v5 computes the positions once and reuses
1732
+ them. At a third of a millisecond per chart this is irrelevant interactively;
1733
+ it is visible only to code building thousands of charts in a batch.
1734
+
1735
+ Cost is dominated by ephemeris evaluations, so the lever that matters is the one
1736
+ that avoids them:
1737
+
1738
+ - **`sections`** — skip the optional ephemeris-backed blocks you don't need.
1739
+ Dropping `'festivals'` takes a default call from ~0.41 ms to ~0.39 ms cold,
1740
+ and dropping everything takes it to ~0.27 ms. See
1741
+ [Narrowing the work](#narrowing-the-work).
1742
+ - **`computeEndTimes: false`** — a small win, never a large one. It skips the
1743
+ transition searches, but those read through the same interpolated longitude
1744
+ blocks the rest of the call has already built, so what it saves is arithmetic
1745
+ rather than ephemeris: ~5% cold, ~10% warm. Use it to drop `endTime` fields
1746
+ you don't want, not to go faster.
1747
+
1748
+ *Changed in v5.* Both entry points now always interpolate, so output depends
1749
+ on neither `sections` nor `computeEndTimes` — see `INTERPOLATE_ALWAYS` in
1750
+ `src/core/panchang.ts`. Earlier development builds chose the longitude cache's
1751
+ mode from `computeEndTimes`, which made asking for *less* output cost *more*
1752
+ on a full call; that is gone.
1753
+
1754
+ Repeated calls for the same location-day are cheaper because solar rise/set
1755
+ events are cached process-wide, keyed on `(direction, lat, lon, elevation, UTC
1756
+ day)` and bounded at 20,000 entries. The cache makes sunrise single-valued as
1757
+ well as fast — see [Upgrading from 4.x](#sunrise-is-single-valued-per-location-day).
1758
+ It does not make a *single* cold rise/set call cheaper — against 4.3.1
1759
+ `getSunrise` is +1.8%, `getSunset` +8.8%, `getMoonrise` +5.5% and `getMoonset`
1760
+ +6.6%, i.e. unchanged to slightly worse — what it removes is the second and
1761
+ every later call for the same day.
1762
+
1763
+ Range helpers apply the same narrowing internally:
1764
+ `computeEkadashiDatesForYear` reads only the tithi at sunrise and so runs with
1765
+ every optional section off (**~18 ms** for a full year, against ~2,360 ms in
1766
+ 4.3.1); `computeFestivalsInRange` keeps only `'festivals'` and `'eclipse'`
1767
+ (**~131 ms/year**, against ~2,180); `computeSankrantisForYear` needs only the
1768
+ Sun, so it scans one solar longitude per day and bisects the 12 transits rather
1769
+ than building a panchang each day (**~3.3 ms/year**, against ~154).
1173
1770
 
1174
1771
  Birth-chart helpers are independent — calling them does not add work to
1175
- `getDailyPanchang`.
1772
+ `getDailyPanchang`. Within them, `computeShadbala` and `computeBhavaBala` build
1773
+ the natal positions once and derive all seven charts from them (~0.33 ms each,
1774
+ against ~0.72 in 4.3.1).
1775
+
1776
+ **Charts are the one place v5 is slower.** `computeRashiChart` and
1777
+ `computeNavamsa` cost **~0.32 ms** against ~0.10 in 4.3.1 — 3.3×, entirely the
1778
+ planetary ephemeris, and the deliberate price of an order-of-magnitude accuracy
1779
+ gain against JPL DE441 (Mercury 6.50″ → 0.30″, Venus 19.59″ → 0.86″). If you
1780
+ build many charts and do not need that precision, 4.x was cheaper; nothing else
1781
+ in the library regressed.
1782
+
1783
+ ### Narrowing the work
1784
+
1785
+ `PanchangSection` lists the four optional blocks. Everything else a daily
1786
+ panchang returns — the five elements, slot systems, muhurtas, inauspicious
1787
+ periods, masa / samvat / rashi — is arithmetic over the sunrise / sunset /
1788
+ next-sunrise triplet and is always computed, because skipping it would save
1789
+ nothing.
1790
+
1791
+ | Section | Covers | Fields when omitted |
1792
+ |---|---|---|
1793
+ | `'festivals'` | Festival detection — needs the prior day's sunrise/sunset, the next day's transit, per-kala tithi anchors, and the prior day's Chandra Masa | `festivals: []` — but an eclipse entry is still prepended when `'eclipse'` is on |
1794
+ | `'eclipse'` | Eclipse overlapping the Hindu day | `eclipse: null` |
1795
+ | `'moonTimes'` | `moon.rise` / `moon.set` | `null` |
1796
+ | `'lunarWindows'` | Bhadra, Varjyam, Panchaka-Rahita — each binary-searches lunar longitude across the day | `null` / `[]` |
1797
+
1798
+ ```typescript
1799
+ // Everything (default).
1800
+ getDailyPanchang(date, loc, { timezone: 330 });
1801
+
1802
+ // Festivals only — no moon times, no Bhadra/Varjyam windows.
1803
+ getDailyPanchang(date, loc, {
1804
+ timezone: 330,
1805
+ sections: ['festivals', 'eclipse'],
1806
+ });
1807
+
1808
+ // Cheapest useful call: elements, slots, muhurtas and inauspicious periods
1809
+ // only. Those are arithmetic on the sunrise triplet and are always computed.
1810
+ getDailyPanchang(date, loc, {
1811
+ timezone: 330,
1812
+ sections: [],
1813
+ computeEndTimes: false,
1814
+ });
1815
+ ```
1816
+
1817
+ Omitting a section leaves its fields at their documented empty value (`null`
1818
+ or `[]`) — never a half-filled one.
1819
+
1820
+ Narrowing is **exactly output-neutral**: every field a narrowed call does
1821
+ compute is identical, to the millisecond, to what the full call would have
1822
+ returned. `sections` only decides what is skipped, never what a computed value
1823
+ is. (This is guaranteed by `LongitudeCache` memoizing on the exact instant. It
1824
+ was not true while that memo binned longitudes into 60-second buckets, when
1825
+ narrowing could shift transition times by up to 63 s.)
1826
+
1827
+ ### A note on Hermes / React Native
1828
+
1829
+ Earlier versions of this table also quoted Hermes figures. Those were budget
1830
+ targets from the project plan, never measurements: `npm run test:hermes` runs
1831
+ `hermes-parser` over the built bundle to prove the syntax is Hermes-compatible,
1832
+ which is a *parse* check and does not execute anything. Hermes numbers will be
1833
+ published here once they are actually measured on device.
1834
+
1835
+ What does carry over is the shape of the cost: it is dominated by ephemeris
1836
+ math, so the `sections` and `computeEndTimes` levers above have the same
1837
+ proportional effect on any runtime.
1176
1838
 
1177
1839
  ---
1178
1840