panchanga 0.1.1
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.
- package/LICENSE +21 -0
- package/README.md +272 -0
- package/dist/ayanamsha.d.ts +84 -0
- package/dist/ayanamsha.d.ts.map +1 -0
- package/dist/ayanamsha.js +124 -0
- package/dist/ayanamsha.js.map +1 -0
- package/dist/eclipses.d.ts +58 -0
- package/dist/eclipses.d.ts.map +1 -0
- package/dist/eclipses.js +132 -0
- package/dist/eclipses.js.map +1 -0
- package/dist/elements.d.ts +230 -0
- package/dist/elements.d.ts.map +1 -0
- package/dist/elements.js +603 -0
- package/dist/elements.js.map +1 -0
- package/dist/festivals.d.ts +145 -0
- package/dist/festivals.d.ts.map +1 -0
- package/dist/festivals.js +927 -0
- package/dist/festivals.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/dist/panchanga.d.ts +78 -0
- package/dist/panchanga.d.ts.map +1 -0
- package/dist/panchanga.js +76 -0
- package/dist/panchanga.js.map +1 -0
- package/dist/rules.d.ts +161 -0
- package/dist/rules.d.ts.map +1 -0
- package/dist/rules.js +1058 -0
- package/dist/rules.js.map +1 -0
- package/dist/time.d.ts +306 -0
- package/dist/time.d.ts.map +1 -0
- package/dist/time.js +637 -0
- package/dist/time.js.map +1 -0
- package/dist/types.d.ts +169 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +22 -0
- package/dist/types.js.map +1 -0
- package/package.json +57 -0
|
@@ -0,0 +1,927 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/festivals.ts — the observance-rule EVALUATOR.
|
|
3
|
+
*
|
|
4
|
+
* Two responsibilities:
|
|
5
|
+
*
|
|
6
|
+
* 1. `selectDayByPervasion` — a PURE, independently-testable selection
|
|
7
|
+
* function. Given a set of candidate civil days (each with its tithi
|
|
8
|
+
* interval ∩ the day's kāla window, plus optional nakshatra / Bhadra
|
|
9
|
+
* facts), it applies the precedence policy, the nakshatra filter/tie-break,
|
|
10
|
+
* and records Bhadra overlap. No ephemeris, no I/O — the keystone logic the
|
|
11
|
+
* constructed-case tests target.
|
|
12
|
+
*
|
|
13
|
+
* 2. `computeFestival` / `computeFestivals` — resolve each `Observance.kind`
|
|
14
|
+
* to a civil date using the real astronomy modules (`time.ts`,
|
|
15
|
+
* `elements.ts`). These build the candidate set for `tithi-pervades` and
|
|
16
|
+
* feed it to `selectDayByPervasion`; the other kinds resolve directly.
|
|
17
|
+
*
|
|
18
|
+
* NEVER SILENTLY DROP: a rule that yields no date still returns a
|
|
19
|
+
* `FestivalResult` (empty `date`) carrying a diagnostic, plus a top-level
|
|
20
|
+
* diagnostic from `computeFestivals`.
|
|
21
|
+
*
|
|
22
|
+
* SCOPE: this is the MECHANISM (Task 5a). The per-festival rule DATA
|
|
23
|
+
* (`src/rules.ts`) and the Drik-Panchang conformance gate are Task 5b/Phase 4.
|
|
24
|
+
*/
|
|
25
|
+
import { tithiBoundaries, nakshatraAt, bhadraIntervals, bhadraSplit, newMoons, solarIngress, lunarMonth, NAKSHATRA_NAMES, } from "./elements.js";
|
|
26
|
+
import { siderealSunRashi } from "./ayanamsha.js";
|
|
27
|
+
import { validateLocation, startOfLocalDayUTC, nextLocalDayStartUTC, localDayString, moonrise, sunset, riseSet, sankrantiPunyaKala, sunriseWindow, purvahna, madhyahna, aparahna, pradosha, nishita, brahmaMuhurta, arunodaya, } from "./time.js";
|
|
28
|
+
/** Overlap length (ms, ≥0) between two intervals. */
|
|
29
|
+
function overlapMs(a, b) {
|
|
30
|
+
const s = Math.max(a.start.getTime(), b.start.getTime());
|
|
31
|
+
const e = Math.min(a.end.getTime(), b.end.getTime());
|
|
32
|
+
return Math.max(0, e - s);
|
|
33
|
+
}
|
|
34
|
+
/** Fraction of `window` that `tithi` covers (0..1). */
|
|
35
|
+
function windowFraction(tithi, window) {
|
|
36
|
+
const wLen = window.end.getTime() - window.start.getTime();
|
|
37
|
+
if (wLen <= 0)
|
|
38
|
+
return 0;
|
|
39
|
+
return overlapMs(tithi, window) / wLen;
|
|
40
|
+
}
|
|
41
|
+
/** Is the festival tithi live at instant `t` (half-open: start ≤ t < end)? */
|
|
42
|
+
function tithiLiveAt(interval, t) {
|
|
43
|
+
return interval.start.getTime() <= t.getTime() && interval.end.getTime() > t.getTime();
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Temporal gap (ms, ≥0) between the tithi interval and the kāla window: 0 if
|
|
47
|
+
* they overlap, otherwise the size of the bare interval separating them. Used by
|
|
48
|
+
* the `nearest-window` fallback to pick the candidate whose window sits closest
|
|
49
|
+
* to the tithi when none actually pervades.
|
|
50
|
+
*/
|
|
51
|
+
function tithiWindowGap(c) {
|
|
52
|
+
const t = c.tithiInterval;
|
|
53
|
+
const w = c.window;
|
|
54
|
+
if (overlapMs(t, w) > 0)
|
|
55
|
+
return 0;
|
|
56
|
+
return Math.max(w.start.getTime() - t.end.getTime(), t.start.getTime() - w.end.getTime());
|
|
57
|
+
}
|
|
58
|
+
/** Is the tithi live at the window's start instant (udaya)? */
|
|
59
|
+
function presentAtStart(c) {
|
|
60
|
+
return tithiLiveAt(c.tithiInterval, c.window.start);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Select the observance day among `candidates` per the precedence policy.
|
|
64
|
+
*
|
|
65
|
+
* Pipeline:
|
|
66
|
+
* 1. `nakshatra:"required"` → drop candidates with `nakshatraOk === false`.
|
|
67
|
+
* 1b.`avoidKarana:"vishti"` → DISQUALIFY any candidate whose kāla window is
|
|
68
|
+
* wholly covered by Bhadra (Viṣṭi) — i.e. `bhadraFreeWindow === false`.
|
|
69
|
+
* The observance must be performed in a Bhadra-FREE window, so a fully
|
|
70
|
+
* Bhadra-contaminated day cannot host it. (Holikā Dahan, Rakṣā Bandhan.)
|
|
71
|
+
* 2. Keep only candidates whose tithi covers a positive fraction of the
|
|
72
|
+
* window (i.e. the tithi actually pervades the window at all). A day with
|
|
73
|
+
* zero coverage cannot win — that is what triggers `fallback`.
|
|
74
|
+
* 3. Apply the precedence policy to the survivors:
|
|
75
|
+
* • max-window-fraction → largest coverage fraction wins.
|
|
76
|
+
* • udaya → a day present at the window start beats one
|
|
77
|
+
* that is not; among equals, larger fraction.
|
|
78
|
+
* • first / second → earliest / latest survivor by day.
|
|
79
|
+
* 4. `nakshatra:"preferred"` → on an (near-)exact fraction tie, prefer the
|
|
80
|
+
* nakshatra-matching day; never overrides a clear winner.
|
|
81
|
+
* 5. Record the chosen day's Bhadra overlap (avoidKarana:"vishti").
|
|
82
|
+
* 6. If no survivor pervades but `avoidKarana:"vishti"` left exactly the
|
|
83
|
+
* Bhadra-free candidate(s) standing, choose the Bhadra-free day on which
|
|
84
|
+
* the festival tithi is the UDAYA tithi (live at sunrise). This is the
|
|
85
|
+
* Drik resolution for Bhadra-excluded Pūrṇimā festivals: when the natural
|
|
86
|
+
* pradoṣa/aparāhna-vyāpinī day is Bhadra-contaminated, the observance
|
|
87
|
+
* shifts to the (next) udaya-Pūrṇimā day whose window is Bhadra-free, even
|
|
88
|
+
* though the tithi has by then left that evening window.
|
|
89
|
+
* 7. Otherwise, if no survivor, set `fallbackApplied` and explain.
|
|
90
|
+
*
|
|
91
|
+
* PURE: depends only on its arguments.
|
|
92
|
+
*/
|
|
93
|
+
export function selectDayByPervasion(candidates, opts) {
|
|
94
|
+
const diagnostics = [];
|
|
95
|
+
// Sort by day so "first"/"second" and tie-breaks are deterministic.
|
|
96
|
+
const ordered = [...candidates].sort((a, b) => a.day.getTime() - b.day.getTime());
|
|
97
|
+
// 1. Required-nakshatra filter.
|
|
98
|
+
let pool = ordered;
|
|
99
|
+
if (opts.nakshatra === "required") {
|
|
100
|
+
const filtered = pool.filter((c) => c.nakshatraOk !== false);
|
|
101
|
+
if (filtered.length < pool.length) {
|
|
102
|
+
diagnostics.push(`nakshatra(required): dropped ${pool.length - filtered.length} candidate day(s) lacking the required nakshatra`);
|
|
103
|
+
}
|
|
104
|
+
pool = filtered;
|
|
105
|
+
}
|
|
106
|
+
// 1b. Bhadra (Viṣṭi) exclusion. A candidate whose ENTIRE kāla window is
|
|
107
|
+
// covered by Bhadra cannot host a Bhadra-free observance → disqualify it.
|
|
108
|
+
// Candidates with at least a Bhadra-free slice (or no Bhadra at all)
|
|
109
|
+
// survive. We keep the Bhadra-free survivors in `pool` so the normal
|
|
110
|
+
// precedence runs only over admissible days.
|
|
111
|
+
let bhadraDisqualified = [];
|
|
112
|
+
if (opts.avoidKarana === "vishti") {
|
|
113
|
+
const free = pool.filter((c) => c.bhadraFreeWindow !== false);
|
|
114
|
+
bhadraDisqualified = pool.filter((c) => c.bhadraFreeWindow === false);
|
|
115
|
+
if (bhadraDisqualified.length > 0) {
|
|
116
|
+
diagnostics.push(`avoidKarana(vishti): disqualified ${bhadraDisqualified.length} candidate day(s) whose ` +
|
|
117
|
+
`kāla window is wholly covered by Bhadra (Viṣṭi); the observance must be Bhadra-free`);
|
|
118
|
+
}
|
|
119
|
+
pool = free;
|
|
120
|
+
}
|
|
121
|
+
// 2. Keep candidates that actually pervade the window (coverage > 0).
|
|
122
|
+
const withCoverage = pool.map((c) => ({ c, frac: windowFraction(c.tithiInterval, c.window) }));
|
|
123
|
+
const pervading = withCoverage.filter((x) => x.frac > 0);
|
|
124
|
+
if (pervading.length === 0) {
|
|
125
|
+
// Distinguish two causes of an empty survivor set:
|
|
126
|
+
// a) Required-nakshatra wipeout: candidates pervaded but all lacked the
|
|
127
|
+
// required nakshatra. This is NOT a non-pervasion event — do NOT apply
|
|
128
|
+
// the day-fallback; return no date with a nakshatra-specific diagnostic.
|
|
129
|
+
// b) Genuine non-pervasion: the tithi simply did not overlap the kāla window
|
|
130
|
+
// on any candidate day. Apply the fallback (if configured).
|
|
131
|
+
if (pool.length === 0 && opts.nakshatra === "required") {
|
|
132
|
+
// pool was emptied by the required-nakshatra filter (candidates existed but
|
|
133
|
+
// none had the required nakshatra).
|
|
134
|
+
diagnostics.push("required nakshatra not satisfied: candidates pervaded but none had the required nakshatra; no day chosen");
|
|
135
|
+
return {
|
|
136
|
+
chosen: null,
|
|
137
|
+
coverageFraction: 0,
|
|
138
|
+
fallbackApplied: null, // NOT a pervasion failure — fallback does not apply
|
|
139
|
+
bhadraOverlap: null,
|
|
140
|
+
diagnostics,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
// Bhadra-exclusion shift: if the only reason nothing pervades is that a day
|
|
144
|
+
// that DID pervade the window was Bhadra-disqualified, observe on the
|
|
145
|
+
// Bhadra-free udaya-tithi day. This is the Drik resolution for Holikā /
|
|
146
|
+
// Rakṣā when the natural pradoṣa/aparāhna-vyāpinī day is Bhadra-contaminated:
|
|
147
|
+
// the festival shifts to the (next) day on which the tithi is live at
|
|
148
|
+
// sunrise and whose kāla window is Bhadra-free — even though the tithi has
|
|
149
|
+
// by then left that evening window (frac = 0 there).
|
|
150
|
+
//
|
|
151
|
+
// GUARD: require that a disqualified day actually pervaded the window. A
|
|
152
|
+
// bare `bhadraDisqualified.length > 0` would also fire on a GENUINE
|
|
153
|
+
// non-pervasion (tithi absent on every day) that merely coincided with a
|
|
154
|
+
// Bhadra-covered day, wrongly suppressing the configured `fallback`.
|
|
155
|
+
const disqualifiedPervaded = bhadraDisqualified.some((c) => windowFraction(c.tithiInterval, c.window) > 0);
|
|
156
|
+
if (opts.avoidKarana === "vishti" && disqualifiedPervaded && pool.length > 0) {
|
|
157
|
+
// Prefer a Bhadra-free candidate that is the udaya-tithi day; else the
|
|
158
|
+
// earliest Bhadra-free candidate (day-sorted).
|
|
159
|
+
const udaya = pool.find((c) => c.tithiAtSunrise === true);
|
|
160
|
+
const shifted = udaya ?? pool[0];
|
|
161
|
+
diagnostics.push(`avoidKarana(vishti): natural window-pervading day was Bhadra-disqualified; ` +
|
|
162
|
+
`shifted to the Bhadra-free ${udaya ? "udaya-tithi" : "earliest surviving"} day`);
|
|
163
|
+
return {
|
|
164
|
+
chosen: shifted,
|
|
165
|
+
coverageFraction: windowFraction(shifted.tithiInterval, shifted.window),
|
|
166
|
+
fallbackApplied: null,
|
|
167
|
+
// The surviving pool only guarantees the window is NOT WHOLLY Bhadra;
|
|
168
|
+
// a partial overlap can remain on the chosen day, so surface it rather
|
|
169
|
+
// than asserting null.
|
|
170
|
+
bhadraOverlap: shifted.bhadraOverlap ?? null,
|
|
171
|
+
diagnostics,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
// nearest-window fallback: no day pervades the window, but the festival
|
|
175
|
+
// still occurs (e.g. a niśīta-vyāpinī Caturdaśī that straddles two midnights
|
|
176
|
+
// without covering either, at a far-western longitude). Keep the candidate
|
|
177
|
+
// whose window sits closest to the tithi — the day on which the tithi is
|
|
178
|
+
// current going into that night. Resolved here (we hold the candidates),
|
|
179
|
+
// so the caller receives a chosen day rather than a previous/next-day shift.
|
|
180
|
+
if (opts.fallback === "nearest-window" && pool.length > 0) {
|
|
181
|
+
const nearest = pool.reduce((best, c) => (tithiWindowGap(c) < tithiWindowGap(best) ? c : best));
|
|
182
|
+
diagnostics.push("fallback(nearest-window): tithi pervaded no candidate window; chose the day whose window is nearest the tithi");
|
|
183
|
+
return {
|
|
184
|
+
chosen: nearest,
|
|
185
|
+
coverageFraction: 0,
|
|
186
|
+
fallbackApplied: "nearest-window",
|
|
187
|
+
bhadraOverlap: null,
|
|
188
|
+
diagnostics,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
// Genuine non-pervasion: tithi did not overlap the window on any surviving day.
|
|
192
|
+
diagnostics.push("tithi did not pervade the window on any candidate day");
|
|
193
|
+
return {
|
|
194
|
+
chosen: null,
|
|
195
|
+
coverageFraction: 0,
|
|
196
|
+
fallbackApplied: opts.fallback ?? null,
|
|
197
|
+
bhadraOverlap: null,
|
|
198
|
+
diagnostics,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
// 3. Precedence policy.
|
|
202
|
+
let winner;
|
|
203
|
+
switch (opts.precedence) {
|
|
204
|
+
case "max-window-fraction": {
|
|
205
|
+
winner = pervading.reduce((best, x) => (x.frac > best.frac ? x : best));
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
case "udaya": {
|
|
209
|
+
// Prefer a day present at the window start; among those (or among none),
|
|
210
|
+
// fall back to the larger coverage.
|
|
211
|
+
const atStart = pervading.filter((x) => presentAtStart(x.c));
|
|
212
|
+
const search = atStart.length > 0 ? atStart : pervading;
|
|
213
|
+
winner = search.reduce((best, x) => (x.frac > best.frac ? x : best));
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
case "first": {
|
|
217
|
+
winner = pervading[0]; // already day-sorted ascending
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
case "second": {
|
|
221
|
+
winner = pervading[pervading.length - 1];
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
// 4. Preferred-nakshatra tie-break (only on a near-exact fraction tie).
|
|
226
|
+
if (opts.nakshatra === "preferred") {
|
|
227
|
+
const EPS = 1e-6;
|
|
228
|
+
const tied = pervading.filter((x) => Math.abs(x.frac - winner.frac) <= EPS);
|
|
229
|
+
if (tied.length > 1) {
|
|
230
|
+
const preferred = tied.find((x) => x.c.nakshatraOk === true);
|
|
231
|
+
if (preferred) {
|
|
232
|
+
if (preferred.c !== winner.c) {
|
|
233
|
+
diagnostics.push("nakshatra(preferred): broke a fraction tie toward the matching day");
|
|
234
|
+
}
|
|
235
|
+
winner = preferred;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
// 5. Record Bhadra overlap on the chosen day.
|
|
240
|
+
let bhadraOverlap = null;
|
|
241
|
+
if (opts.avoidKarana === "vishti") {
|
|
242
|
+
bhadraOverlap = winner.c.bhadraOverlap ?? null;
|
|
243
|
+
if (bhadraOverlap) {
|
|
244
|
+
diagnostics.push(`avoidKarana(vishti): Bhadra overlaps the window on the chosen day ` +
|
|
245
|
+
`(${bhadraOverlap.start.toISOString()}–${bhadraOverlap.end.toISOString()}); ` +
|
|
246
|
+
`Mukha/Pucchā split and Vāsa recorded in instants (bhadra*)`);
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
diagnostics.push("avoidKarana(vishti): no Bhadra overlaps the chosen day's window");
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
chosen: winner.c,
|
|
254
|
+
coverageFraction: winner.frac,
|
|
255
|
+
fallbackApplied: null,
|
|
256
|
+
bhadraOverlap,
|
|
257
|
+
diagnostics,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
261
|
+
// PART 2 — resolving an Observance against real ephemeris
|
|
262
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
263
|
+
const DAY_MS = 86_400_000;
|
|
264
|
+
/** Map a `Kala` window name to its `time.ts` window function. */
|
|
265
|
+
function kalaWindow(kala, dayInstant, loc) {
|
|
266
|
+
switch (kala) {
|
|
267
|
+
case "sunrise":
|
|
268
|
+
case "pratahkala":
|
|
269
|
+
return sunriseWindow(dayInstant, loc);
|
|
270
|
+
case "purvahna":
|
|
271
|
+
return purvahna(dayInstant, loc);
|
|
272
|
+
case "madhyahna":
|
|
273
|
+
return madhyahna(dayInstant, loc);
|
|
274
|
+
case "aparahna":
|
|
275
|
+
return aparahna(dayInstant, loc);
|
|
276
|
+
case "pradosha":
|
|
277
|
+
return pradosha(dayInstant, loc);
|
|
278
|
+
case "nishita":
|
|
279
|
+
return nishita(dayInstant, loc);
|
|
280
|
+
case "brahmaMuhurta":
|
|
281
|
+
return brahmaMuhurta(dayInstant, loc);
|
|
282
|
+
case "arunodaya":
|
|
283
|
+
return arunodaya(dayInstant, loc);
|
|
284
|
+
default:
|
|
285
|
+
// moonrise / sunset / sankrantiPunyaKala are instants, not kāla windows
|
|
286
|
+
// resolvable here; the tithi-pervades windows are the nine above.
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* The absolute tithi number 1..30 for a {paksha, tithiRef}.
|
|
292
|
+
* • śukla pakṣa → 1..15 (15 = Pūrṇimā)
|
|
293
|
+
* • kṛṣṇa pakṣa → 16..30 (30 = Amāvāsyā)
|
|
294
|
+
*/
|
|
295
|
+
function absoluteTithi(paksha, tithi) {
|
|
296
|
+
let n;
|
|
297
|
+
if (tithi === "purnima")
|
|
298
|
+
n = 15;
|
|
299
|
+
else if (tithi === "amavasya")
|
|
300
|
+
n = 15; // within kṛṣṇa, the 15th → absolute 30
|
|
301
|
+
else
|
|
302
|
+
n = tithi;
|
|
303
|
+
if (n < 1 || n > 15) {
|
|
304
|
+
throw new Error(`absoluteTithi: tithi ${String(tithi)} out of 1..15`);
|
|
305
|
+
}
|
|
306
|
+
return paksha === "shukla" ? n : 15 + n;
|
|
307
|
+
}
|
|
308
|
+
/** ISO-UTC helper. */
|
|
309
|
+
const iso = (d) => d.toISOString();
|
|
310
|
+
const SYNODIC_MS = 29.530588853 * DAY_MS;
|
|
311
|
+
/**
|
|
312
|
+
* Locate the exact `{start,end}` interval of absolute tithi `n` (1..30) within
|
|
313
|
+
* the lunation that begins at `nmStart` (a new moon). Returns null if tithi `n`
|
|
314
|
+
* does not occur in this lunation (should not happen for a normal lunation).
|
|
315
|
+
*
|
|
316
|
+
* Tithis are NOT uniformly 1/30 of a lunation (the Moon's elongation rate
|
|
317
|
+
* varies), so a fixed `n/30` probe can land a tithi off. We make a first guess
|
|
318
|
+
* at (n − 0.5)/30 of the lunation, read the tithi there, then STEP toward `n`
|
|
319
|
+
* by half a tithi at a time (re-reading boundaries) until we land on `n`.
|
|
320
|
+
*/
|
|
321
|
+
// Memoized by (lunation start, n): every tithi rule re-walks the same ~16
|
|
322
|
+
// lunations, and rules sharing a tithi number (all Ekādaśīs, all Pradoṣa, …)
|
|
323
|
+
// ask for the same (nmStart, n). Pure function → behaviour-preserving cache.
|
|
324
|
+
const _tithiIntervalCache = new Map();
|
|
325
|
+
function tithiIntervalInLunation(nmStart, n) {
|
|
326
|
+
const key = `${nmStart.getTime()}:${n}`;
|
|
327
|
+
const cached = _tithiIntervalCache.get(key);
|
|
328
|
+
if (cached !== undefined)
|
|
329
|
+
return cached;
|
|
330
|
+
const result = tithiIntervalInLunationUncached(nmStart, n);
|
|
331
|
+
if (_tithiIntervalCache.size < 50_000)
|
|
332
|
+
_tithiIntervalCache.set(key, result); // bound memory
|
|
333
|
+
return result;
|
|
334
|
+
}
|
|
335
|
+
function tithiIntervalInLunationUncached(nmStart, n) {
|
|
336
|
+
let probeMs = nmStart.getTime() + ((n - 0.5) / 30) * SYNODIC_MS;
|
|
337
|
+
for (let iter = 0; iter < 8; iter++) {
|
|
338
|
+
let tb;
|
|
339
|
+
try {
|
|
340
|
+
tb = tithiBoundaries(new Date(probeMs));
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
if (tb.number === n)
|
|
346
|
+
return { start: tb.start, end: tb.end };
|
|
347
|
+
// Step toward n. Each tithi is ~ SYNODIC/30 wide; move (n − found) tithis.
|
|
348
|
+
const delta = (n - tb.number) * (SYNODIC_MS / 30);
|
|
349
|
+
// Re-centre on the found tithi's midpoint, then add delta.
|
|
350
|
+
const mid = (tb.start.getTime() + tb.end.getTime()) / 2;
|
|
351
|
+
probeMs = mid + delta;
|
|
352
|
+
}
|
|
353
|
+
// Final confirm.
|
|
354
|
+
try {
|
|
355
|
+
const tb = tithiBoundaries(new Date(probeMs));
|
|
356
|
+
if (tb.number === n)
|
|
357
|
+
return { start: tb.start, end: tb.end };
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
/* fall through */
|
|
361
|
+
}
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Find the UTC `{start,end}` interval of absolute tithi `n` (1..30) in the lunar
|
|
366
|
+
* month carrying pūrṇimānta label `monthPurnimanta` within `year`, or null.
|
|
367
|
+
*
|
|
368
|
+
* A given absolute tithi recurs once per lunation. We walk the year's lunations
|
|
369
|
+
* (anchored on `newMoons(year)` plus the bracketing neighbours so Dec/Jan-edge
|
|
370
|
+
* tithis resolve), find tithi `n` in each, and keep the occurrence whose
|
|
371
|
+
* pūrṇimānta month label matches the rule's. More than one match can occur only
|
|
372
|
+
* across an adhika month — we return the earliest and record the rest.
|
|
373
|
+
*/
|
|
374
|
+
function findTithiIntervalInMonth(n, monthPurnimanta, year, diagnostics) {
|
|
375
|
+
const nms = newMoons(year);
|
|
376
|
+
const starts = [];
|
|
377
|
+
if (nms.length > 0)
|
|
378
|
+
starts.push(new Date(nms[0].getTime() - SYNODIC_MS));
|
|
379
|
+
for (const nm of nms)
|
|
380
|
+
starts.push(nm);
|
|
381
|
+
if (nms.length > 0)
|
|
382
|
+
starts.push(new Date(nms[nms.length - 1].getTime() + SYNODIC_MS));
|
|
383
|
+
// Determine whether the rule explicitly requests the adhika lunation.
|
|
384
|
+
// This must be decided BEFORE scanning so we can adjust label matching.
|
|
385
|
+
const requestsAdhika = /adhika/i.test(monthPurnimanta);
|
|
386
|
+
const matches = [];
|
|
387
|
+
const seen = new Set();
|
|
388
|
+
for (const nmStart of starts) {
|
|
389
|
+
const interval = tithiIntervalInLunation(nmStart, n);
|
|
390
|
+
if (!interval)
|
|
391
|
+
continue;
|
|
392
|
+
const key = Math.round(interval.start.getTime() / 1000);
|
|
393
|
+
if (seen.has(key))
|
|
394
|
+
continue;
|
|
395
|
+
seen.add(key);
|
|
396
|
+
// Label check at the tithi's midpoint.
|
|
397
|
+
// In pūrṇimānta the kṛṣṇa pakṣa of an amānta month carries the NEXT amānta
|
|
398
|
+
// month's name (e.g. kṛṣṇa pakṣa of Adhika Jyeṣṭha → purnimantaLabel =
|
|
399
|
+
// "Adhika Āṣāḍha"). When the rule requests an adhika month we therefore
|
|
400
|
+
// also check the AMĀNTA label — but ONLY for genuinely adhika lunations — so
|
|
401
|
+
// that both the śukla and the kṛṣṇa fortnights of the adhika lunation are
|
|
402
|
+
// captured without also matching the nija lunation's kṛṣṇa pakṣa.
|
|
403
|
+
const mid = new Date((interval.start.getTime() + interval.end.getTime()) / 2);
|
|
404
|
+
const lm = lunarMonth(mid, { system: "purnimanta" });
|
|
405
|
+
const purnimantaMatch = normalizeLabel(lm.purnimantaLabel) === normalizeLabel(monthPurnimanta);
|
|
406
|
+
// Amānta fallback: only used when requesting an adhika month AND the tithi
|
|
407
|
+
// itself belongs to an adhika lunation (prevents nija kṛṣṇa-pakṣa false hits).
|
|
408
|
+
const amantaMatch = requestsAdhika && lm.adhika &&
|
|
409
|
+
normalizeLabel(lm.amantaLabel) === normalizeLabel(monthPurnimanta);
|
|
410
|
+
if (purnimantaMatch || amantaMatch) {
|
|
411
|
+
matches.push({ interval, adhika: lm.adhika });
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
if (matches.length === 0) {
|
|
415
|
+
if (requestsAdhika) {
|
|
416
|
+
diagnostics.push(`no Adhika ${normalizeLabel(monthPurnimanta)} lunation found in ${year}; ` +
|
|
417
|
+
`rule "${monthPurnimanta}" requires an adhika month that does not exist this year`);
|
|
418
|
+
}
|
|
419
|
+
else {
|
|
420
|
+
diagnostics.push(`no tithi ${n} found in pūrṇimānta month "${monthPurnimanta}" during ${year}`);
|
|
421
|
+
}
|
|
422
|
+
return null;
|
|
423
|
+
}
|
|
424
|
+
if (matches.length > 1) {
|
|
425
|
+
// In a leap year the same normalized month name appears in both the Adhika and
|
|
426
|
+
// the Nija (regular) lunation.
|
|
427
|
+
if (requestsAdhika) {
|
|
428
|
+
// Rule explicitly targets the Adhika lunation (e.g. "Adhika Jyeshtha").
|
|
429
|
+
const adhika = matches.find((m) => m.adhika);
|
|
430
|
+
if (adhika) {
|
|
431
|
+
diagnostics.push(`${matches.length} occurrences of tithi ${n} in "${monthPurnimanta}" ${year} ` +
|
|
432
|
+
`(adhika month present); rule requests Adhika — selecting the adhika lunation`);
|
|
433
|
+
return adhika.interval;
|
|
434
|
+
}
|
|
435
|
+
// No adhika lunation found among matches despite requestsAdhika — should not
|
|
436
|
+
// happen if normalizeLabel correctly stripped the prefix, but guard anyway.
|
|
437
|
+
diagnostics.push(`rule "${monthPurnimanta}" requests an adhika lunation but none was found ` +
|
|
438
|
+
`among ${matches.length} matches in ${year}; returning null`);
|
|
439
|
+
return null;
|
|
440
|
+
}
|
|
441
|
+
else {
|
|
442
|
+
// Festivals canonically fall in the Nija month; prefer the non-adhika occurrence.
|
|
443
|
+
const nija = matches.find((m) => !m.adhika);
|
|
444
|
+
if (nija) {
|
|
445
|
+
diagnostics.push(`${matches.length} occurrences of tithi ${n} in "${monthPurnimanta}" ${year} ` +
|
|
446
|
+
`(adhika month present); preferring the Nija (non-adhika) lunation`);
|
|
447
|
+
return nija.interval;
|
|
448
|
+
}
|
|
449
|
+
// All matches are adhika (unusual) — fall back to earliest.
|
|
450
|
+
diagnostics.push(`${matches.length} occurrences of tithi ${n} in "${monthPurnimanta}" ${year} ` +
|
|
451
|
+
`(all adhika?); using the earliest`);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
if (requestsAdhika && matches.length === 1) {
|
|
455
|
+
// Single match for an adhika-requested rule — verify it is actually the adhika lunation.
|
|
456
|
+
if (!matches[0].adhika) {
|
|
457
|
+
diagnostics.push(`rule "${monthPurnimanta}" requests an adhika lunation but only a non-adhika ` +
|
|
458
|
+
`match was found in ${year}; no Adhika ${normalizeLabel(monthPurnimanta)} ` +
|
|
459
|
+
`lunation exists this year`);
|
|
460
|
+
return null;
|
|
461
|
+
}
|
|
462
|
+
// Single adhika match — return it directly.
|
|
463
|
+
return matches[0].interval;
|
|
464
|
+
}
|
|
465
|
+
matches.sort((a, b) => a.interval.start.getTime() - b.interval.start.getTime());
|
|
466
|
+
return matches[0].interval;
|
|
467
|
+
}
|
|
468
|
+
/** Normalise a month label for comparison (lower, strip Adhika/Nija/Shuddha). */
|
|
469
|
+
function normalizeLabel(s) {
|
|
470
|
+
return s.toLowerCase().replace(/\b(adhika|nija|shuddha|sudha)\b/g, "").trim();
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* The set of civil days a tithi interval touches in `loc`'s tz — usually one or
|
|
474
|
+
* two (a tithi spans ~24h and can straddle two civil days). Returned as the
|
|
475
|
+
* UTC instant of each local-day midnight, ascending.
|
|
476
|
+
*/
|
|
477
|
+
function civilDaysTouched(interval, loc) {
|
|
478
|
+
const firstDay = startOfLocalDayUTC(interval.start, loc.timeZone);
|
|
479
|
+
const days = [firstDay];
|
|
480
|
+
// Walk forward by local day until we pass the interval end.
|
|
481
|
+
let cursor = firstDay;
|
|
482
|
+
for (let i = 0; i < 3; i++) {
|
|
483
|
+
const next = nextLocalDayStartUTC(cursor, loc.timeZone);
|
|
484
|
+
if (next.getTime() > interval.end.getTime())
|
|
485
|
+
break;
|
|
486
|
+
days.push(next);
|
|
487
|
+
cursor = next;
|
|
488
|
+
}
|
|
489
|
+
return days;
|
|
490
|
+
}
|
|
491
|
+
/** Resolve a `tithi-pervades` observance to a FestivalResult-ready payload. */
|
|
492
|
+
function resolveTithiPervades(obs, year, loc, monthPurnimanta, diagnostics) {
|
|
493
|
+
const n = absoluteTithi(obs.paksha, obs.tithi);
|
|
494
|
+
// Adhika-māsa policy: a "prefer-adhika" rule observes in the leap lunation of
|
|
495
|
+
// the named month when one exists this year (e.g. Ganga Dussehra → Adhika
|
|
496
|
+
// Jyeṣṭha). Probe the "Adhika <month>" label first with a throwaway diagnostic
|
|
497
|
+
// sink; if absent (an ordinary year), fall through to the nija lunation.
|
|
498
|
+
let interval = null;
|
|
499
|
+
if (obs.adhika === "prefer-adhika" && !/adhika/i.test(monthPurnimanta)) {
|
|
500
|
+
interval = findTithiIntervalInMonth(n, `Adhika ${monthPurnimanta}`, year, []);
|
|
501
|
+
if (interval)
|
|
502
|
+
diagnostics.push(`adhika(prefer-adhika): observing in the Adhika ${monthPurnimanta} lunation`);
|
|
503
|
+
}
|
|
504
|
+
if (!interval)
|
|
505
|
+
interval = findTithiIntervalInMonth(n, monthPurnimanta, year, diagnostics);
|
|
506
|
+
const instants = {};
|
|
507
|
+
if (!interval)
|
|
508
|
+
return { day: null, instants };
|
|
509
|
+
instants.tithiStart = iso(interval.start);
|
|
510
|
+
instants.tithiEnd = iso(interval.end);
|
|
511
|
+
// Build a candidate per civil day the tithi touches.
|
|
512
|
+
const days = civilDaysTouched(interval, loc);
|
|
513
|
+
// Precompute Bhadra (Viṣṭi) spans once per festival: the candidate days all
|
|
514
|
+
// belong to one lunation, so a single ±synodic scan around the tithi covers
|
|
515
|
+
// every day's window — no need to rescan (16 SearchMoonPhase calls) per day.
|
|
516
|
+
const bhadraSet = obs.avoidKarana === "vishti" ? bhadraIntervals(interval.start) : null;
|
|
517
|
+
const candidates = [];
|
|
518
|
+
for (const dayMidnight of days) {
|
|
519
|
+
const win = kalaWindow(obs.window, dayMidnight, loc);
|
|
520
|
+
if (!win) {
|
|
521
|
+
diagnostics.push(`kāla window "${obs.window}" unavailable on ${localDayString(dayMidnight, loc.timeZone)} (polar?)`);
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
// Nakshatra fact at the window's anchor instant (its start).
|
|
525
|
+
let nakshatraOk;
|
|
526
|
+
if (obs.nakshatra) {
|
|
527
|
+
const idx = nakshatraAt(win.start);
|
|
528
|
+
nakshatraOk = NAKSHATRA_NAMES[idx] === obs.nakshatra.name;
|
|
529
|
+
}
|
|
530
|
+
// Bhadra overlap with the window, if requested. We record BOTH the first
|
|
531
|
+
// overlapping interval (for diagnostics) and whether ANY part of the window
|
|
532
|
+
// is Bhadra-free (so the selector can disqualify a wholly-contaminated day).
|
|
533
|
+
let bhadra;
|
|
534
|
+
let bhadraFreeWindow;
|
|
535
|
+
let tithiAtSunrise;
|
|
536
|
+
if (obs.avoidKarana === "vishti") {
|
|
537
|
+
bhadra = null;
|
|
538
|
+
const winLen = win.end.getTime() - win.start.getTime();
|
|
539
|
+
let coveredMs = 0;
|
|
540
|
+
for (const bi of bhadraSet ?? []) {
|
|
541
|
+
const ov = overlapMs(bi, { start: win.start, end: win.end });
|
|
542
|
+
if (ov > 0) {
|
|
543
|
+
if (!bhadra)
|
|
544
|
+
bhadra = { start: bi.start, end: bi.end };
|
|
545
|
+
coveredMs += ov;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
// Bhadra-free iff a meaningful slice of the window is uncovered. Allow a
|
|
549
|
+
// 1-second slack for boundary/rounding noise, but never let the slack
|
|
550
|
+
// exceed half the window — otherwise a sub-second window would push the
|
|
551
|
+
// threshold negative and misclassify a Bhadra-FREE day as wholly covered.
|
|
552
|
+
const slack = Math.min(1000, winLen / 2);
|
|
553
|
+
bhadraFreeWindow = coveredMs < winLen - slack;
|
|
554
|
+
// Udaya fact: is the festival tithi live at this day's sunrise? Used to
|
|
555
|
+
// pick the Bhadra-free observance day when the tithi has left the window.
|
|
556
|
+
const sr = riseSet("rise", dayMidnight, loc);
|
|
557
|
+
if (sr)
|
|
558
|
+
tithiAtSunrise = tithiLiveAt(interval, sr);
|
|
559
|
+
}
|
|
560
|
+
candidates.push({
|
|
561
|
+
day: dayMidnight,
|
|
562
|
+
tithiInterval: interval,
|
|
563
|
+
window: { start: win.start, end: win.end },
|
|
564
|
+
nakshatraOk,
|
|
565
|
+
bhadraOverlap: bhadra,
|
|
566
|
+
bhadraFreeWindow,
|
|
567
|
+
tithiAtSunrise,
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
if (candidates.length === 0) {
|
|
571
|
+
diagnostics.push("tithi-pervades: no usable candidate day (windows unavailable)");
|
|
572
|
+
return { day: null, instants };
|
|
573
|
+
}
|
|
574
|
+
const sel = selectDayByPervasion(candidates, {
|
|
575
|
+
precedence: obs.precedence,
|
|
576
|
+
nakshatra: obs.nakshatra?.mode,
|
|
577
|
+
avoidKarana: obs.avoidKarana,
|
|
578
|
+
fallback: obs.fallback,
|
|
579
|
+
});
|
|
580
|
+
for (const d of sel.diagnostics)
|
|
581
|
+
diagnostics.push(d);
|
|
582
|
+
let chosenDay = sel.chosen ? sel.chosen.day : null;
|
|
583
|
+
// Apply fallback when nothing pervaded.
|
|
584
|
+
if (!chosenDay && sel.fallbackApplied) {
|
|
585
|
+
const base = days[0];
|
|
586
|
+
chosenDay =
|
|
587
|
+
sel.fallbackApplied === "previous-day"
|
|
588
|
+
? startOfLocalDayUTC(new Date(base.getTime() - DAY_MS), loc.timeZone)
|
|
589
|
+
: nextLocalDayStartUTC(days[days.length - 1], loc.timeZone);
|
|
590
|
+
diagnostics.push(`fallback applied: ${sel.fallbackApplied}`);
|
|
591
|
+
}
|
|
592
|
+
if (chosenDay) {
|
|
593
|
+
const win = kalaWindow(obs.window, chosenDay, loc);
|
|
594
|
+
if (win) {
|
|
595
|
+
instants.windowStart = iso(win.start);
|
|
596
|
+
instants.windowEnd = iso(win.end);
|
|
597
|
+
}
|
|
598
|
+
if (sel.bhadraOverlap) {
|
|
599
|
+
instants.bhadraStart = iso(sel.bhadraOverlap.start);
|
|
600
|
+
instants.bhadraEnd = iso(sel.bhadraOverlap.end);
|
|
601
|
+
// Mukha (avoid) / Pucchā (auspicious) split + Vāsa of this Bhadra span.
|
|
602
|
+
const split = bhadraSplit(sel.bhadraOverlap);
|
|
603
|
+
instants.bhadraVasa = split.vasa;
|
|
604
|
+
instants.bhadraMukhaStart = iso(split.mukha.start);
|
|
605
|
+
instants.bhadraMukhaEnd = iso(split.mukha.end);
|
|
606
|
+
instants.bhadraPucchaStart = iso(split.puccha.start);
|
|
607
|
+
instants.bhadraPucchaEnd = iso(split.puccha.end);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
return { day: chosenDay, instants };
|
|
611
|
+
}
|
|
612
|
+
/** Resolve a `solar-ingress` observance. */
|
|
613
|
+
function resolveSolarIngress(obs, year, loc, diagnostics) {
|
|
614
|
+
const instants = {};
|
|
615
|
+
let moment;
|
|
616
|
+
try {
|
|
617
|
+
moment = solarIngress(year, obs.rashi);
|
|
618
|
+
}
|
|
619
|
+
catch (e) {
|
|
620
|
+
diagnostics.push(`solar-ingress: ${e.message}`);
|
|
621
|
+
return { day: null, instants };
|
|
622
|
+
}
|
|
623
|
+
instants.ingress = iso(moment);
|
|
624
|
+
const punya = sankrantiPunyaKala(moment, loc);
|
|
625
|
+
if (punya) {
|
|
626
|
+
instants.punyaKalaStart = iso(punya.start);
|
|
627
|
+
instants.punyaKalaEnd = iso(punya.end);
|
|
628
|
+
}
|
|
629
|
+
else {
|
|
630
|
+
diagnostics.push("solar-ingress: puṇya-kāla unavailable (polar?)");
|
|
631
|
+
}
|
|
632
|
+
// The Sankrānti's civil DATE is the ingress day — the day the Sun enters the
|
|
633
|
+
// rāśi (this is what panchāṅgas mark). The puṇya-kāla window recorded above
|
|
634
|
+
// still encodes the after-sunset → next-morning shift used for the snāna
|
|
635
|
+
// timing, so that information is preserved in the instants.
|
|
636
|
+
const day = startOfLocalDayUTC(moment, loc.timeZone);
|
|
637
|
+
return { day, instants };
|
|
638
|
+
}
|
|
639
|
+
/** Resolve a `moonrise` observance: tithi live at moonrise. */
|
|
640
|
+
function resolveMoonrise(obs, year, loc, monthPurnimanta, diagnostics) {
|
|
641
|
+
const instants = {};
|
|
642
|
+
const n = absoluteTithi(obs.paksha, obs.tithi);
|
|
643
|
+
const interval = findTithiIntervalInMonth(n, monthPurnimanta, year, diagnostics);
|
|
644
|
+
if (!interval)
|
|
645
|
+
return { day: null, instants };
|
|
646
|
+
instants.tithiStart = iso(interval.start);
|
|
647
|
+
instants.tithiEnd = iso(interval.end);
|
|
648
|
+
// Examine each civil day the tithi touches; pick the one whose moonrise falls
|
|
649
|
+
// inside the tithi interval.
|
|
650
|
+
const days = civilDaysTouched(interval, loc);
|
|
651
|
+
for (const dayMidnight of days) {
|
|
652
|
+
const mr = moonrise(dayMidnight, loc);
|
|
653
|
+
if (!mr)
|
|
654
|
+
continue;
|
|
655
|
+
if (mr.getTime() >= interval.start.getTime() && mr.getTime() < interval.end.getTime()) {
|
|
656
|
+
instants.moonrise = iso(mr);
|
|
657
|
+
return { day: dayMidnight, instants };
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
// No moonrise fell inside the tithi: fall back to the day whose moonrise is
|
|
661
|
+
// nearest after the tithi start (best-effort) and record a diagnostic.
|
|
662
|
+
let best = null;
|
|
663
|
+
for (const dayMidnight of days) {
|
|
664
|
+
const mr = moonrise(dayMidnight, loc);
|
|
665
|
+
if (!mr)
|
|
666
|
+
continue;
|
|
667
|
+
if (!best || mr.getTime() < best.mr.getTime())
|
|
668
|
+
best = { day: dayMidnight, mr };
|
|
669
|
+
}
|
|
670
|
+
if (best) {
|
|
671
|
+
instants.moonrise = iso(best.mr);
|
|
672
|
+
diagnostics.push("moonrise: tithi not live at any moonrise; used nearest moonrise day");
|
|
673
|
+
return { day: best.day, instants };
|
|
674
|
+
}
|
|
675
|
+
diagnostics.push("moonrise: no moonrise on any candidate day (polar?)");
|
|
676
|
+
return { day: null, instants };
|
|
677
|
+
}
|
|
678
|
+
/** Resolve a `solar-arghya` observance: tithi at sunset + next sunrise. */
|
|
679
|
+
function resolveSolarArghya(obs, year, loc, monthPurnimanta, diagnostics) {
|
|
680
|
+
const instants = {};
|
|
681
|
+
const n = absoluteTithi(obs.paksha, obs.tithi);
|
|
682
|
+
const interval = findTithiIntervalInMonth(n, monthPurnimanta, year, diagnostics);
|
|
683
|
+
if (!interval)
|
|
684
|
+
return { day: null, instants };
|
|
685
|
+
instants.tithiStart = iso(interval.start);
|
|
686
|
+
instants.tithiEnd = iso(interval.end);
|
|
687
|
+
const days = civilDaysTouched(interval, loc);
|
|
688
|
+
// The arghya day = the day whose sunset falls inside the tithi interval.
|
|
689
|
+
let chosen = null;
|
|
690
|
+
for (const dayMidnight of days) {
|
|
691
|
+
const ss = sunset(dayMidnight, loc);
|
|
692
|
+
if (!ss)
|
|
693
|
+
continue;
|
|
694
|
+
if (ss.getTime() >= interval.start.getTime() && ss.getTime() < interval.end.getTime()) {
|
|
695
|
+
chosen = dayMidnight;
|
|
696
|
+
instants.sandhyaArghya = iso(ss); // evening offering
|
|
697
|
+
break;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
if (!chosen) {
|
|
701
|
+
// Fall back to the first day with a sunset.
|
|
702
|
+
for (const dayMidnight of days) {
|
|
703
|
+
const ss = sunset(dayMidnight, loc);
|
|
704
|
+
if (ss) {
|
|
705
|
+
chosen = dayMidnight;
|
|
706
|
+
instants.sandhyaArghya = iso(ss);
|
|
707
|
+
diagnostics.push("solar-arghya: tithi not live at any sunset; used first sunset day");
|
|
708
|
+
break;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
if (!chosen) {
|
|
713
|
+
diagnostics.push("solar-arghya: no sunset on any candidate day (polar?)");
|
|
714
|
+
return { day: null, instants };
|
|
715
|
+
}
|
|
716
|
+
// Uṣā arghya = the NEXT morning's sunrise.
|
|
717
|
+
const nextDay = nextLocalDayStartUTC(chosen, loc.timeZone);
|
|
718
|
+
const sr = riseSet("rise", nextDay, loc);
|
|
719
|
+
if (sr)
|
|
720
|
+
instants.ushaArghya = iso(sr);
|
|
721
|
+
else
|
|
722
|
+
diagnostics.push("solar-arghya: next-morning sunrise unavailable (polar?)");
|
|
723
|
+
return { day: chosen, instants };
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* Resolve a `nakshatra-pervades` observance: the civil day on which the Moon
|
|
727
|
+
* occupies the named nakṣatra at sunrise while the Sun is in `solarRashi`. We
|
|
728
|
+
* scan the solar month (from its ingress to the next rāśi's) and return the
|
|
729
|
+
* first day whose sunrise carries the target nakṣatra. (Onam = Śravaṇa in Siṃha.)
|
|
730
|
+
*/
|
|
731
|
+
function resolveNakshatraPervades(obs, year, loc, diagnostics) {
|
|
732
|
+
const instants = {};
|
|
733
|
+
const nakIdx = NAKSHATRA_NAMES.indexOf(obs.nakshatra);
|
|
734
|
+
if (nakIdx < 0) {
|
|
735
|
+
diagnostics.push(`nakshatra-pervades: unknown nakshatra "${obs.nakshatra}"`);
|
|
736
|
+
return { day: null, instants };
|
|
737
|
+
}
|
|
738
|
+
let start;
|
|
739
|
+
let end;
|
|
740
|
+
try {
|
|
741
|
+
start = solarIngress(year, obs.solarRashi);
|
|
742
|
+
end = solarIngress(year, (obs.solarRashi + 1) % 12);
|
|
743
|
+
}
|
|
744
|
+
catch (e) {
|
|
745
|
+
diagnostics.push(`nakshatra-pervades: ${e.message}`);
|
|
746
|
+
return { day: null, instants };
|
|
747
|
+
}
|
|
748
|
+
// If the next ingress wrapped before `start` (Mīna→Mesha across the year
|
|
749
|
+
// boundary), the month runs into the next year — extend the scan window.
|
|
750
|
+
const endMs = end.getTime() > start.getTime() ? end.getTime() : start.getTime() + 32 * DAY_MS;
|
|
751
|
+
instants.solarMonthStart = iso(start);
|
|
752
|
+
let cursor = startOfLocalDayUTC(start, loc.timeZone);
|
|
753
|
+
for (let i = 0; i < 40 && cursor.getTime() < endMs; i++) {
|
|
754
|
+
const sr = riseSet("rise", cursor, loc);
|
|
755
|
+
if (sr && nakshatraAt(sr) === nakIdx && siderealSunRashi(sr) === obs.solarRashi) {
|
|
756
|
+
instants.sunrise = iso(sr);
|
|
757
|
+
instants.sunriseNakshatra = obs.nakshatra;
|
|
758
|
+
return { day: cursor, instants };
|
|
759
|
+
}
|
|
760
|
+
cursor = nextLocalDayStartUTC(cursor, loc.timeZone);
|
|
761
|
+
}
|
|
762
|
+
diagnostics.push(`nakshatra-pervades: ${obs.nakshatra} not found at sunrise during the Sun's transit of rāśi ${obs.solarRashi} in ${year}`);
|
|
763
|
+
return { day: null, instants };
|
|
764
|
+
}
|
|
765
|
+
/**
|
|
766
|
+
* Compute one festival's result. `resolved` supplies already-computed results
|
|
767
|
+
* for `derived` rules to reference (their `from` id → FestivalResult).
|
|
768
|
+
*/
|
|
769
|
+
export function computeFestival(rule, year, loc, resolved) {
|
|
770
|
+
validateLocation(loc);
|
|
771
|
+
const diagnostics = [];
|
|
772
|
+
const obs = rule.observance;
|
|
773
|
+
let day = null;
|
|
774
|
+
let instants = {};
|
|
775
|
+
switch (obs.kind) {
|
|
776
|
+
case "tithi-pervades": {
|
|
777
|
+
const r = resolveTithiPervades(obs, year, loc, rule.month?.purnimanta ?? "", diagnostics);
|
|
778
|
+
day = r.day;
|
|
779
|
+
instants = r.instants;
|
|
780
|
+
break;
|
|
781
|
+
}
|
|
782
|
+
case "solar-ingress": {
|
|
783
|
+
const r = resolveSolarIngress(obs, year, loc, diagnostics);
|
|
784
|
+
day = r.day;
|
|
785
|
+
instants = r.instants;
|
|
786
|
+
break;
|
|
787
|
+
}
|
|
788
|
+
case "moonrise": {
|
|
789
|
+
const r = resolveMoonrise(obs, year, loc, rule.month?.purnimanta ?? "", diagnostics);
|
|
790
|
+
day = r.day;
|
|
791
|
+
instants = r.instants;
|
|
792
|
+
break;
|
|
793
|
+
}
|
|
794
|
+
case "solar-arghya": {
|
|
795
|
+
const r = resolveSolarArghya(obs, year, loc, rule.month?.purnimanta ?? "", diagnostics);
|
|
796
|
+
day = r.day;
|
|
797
|
+
instants = r.instants;
|
|
798
|
+
break;
|
|
799
|
+
}
|
|
800
|
+
case "derived": {
|
|
801
|
+
const baseRes = resolved?.get(obs.from);
|
|
802
|
+
if (!baseRes) {
|
|
803
|
+
diagnostics.push(`derived: referenced rule "${obs.from}" not found / not yet computed`);
|
|
804
|
+
}
|
|
805
|
+
else if (!baseRes.date) {
|
|
806
|
+
diagnostics.push(`derived: referenced rule "${obs.from}" has no date`);
|
|
807
|
+
}
|
|
808
|
+
else {
|
|
809
|
+
const base = new Date(`${baseRes.date}T00:00:00Z`);
|
|
810
|
+
base.setUTCDate(base.getUTCDate() + obs.offsetDays);
|
|
811
|
+
// The offset is in civil days; the base date already is a civil date in
|
|
812
|
+
// loc's tz, so a UTC-date add keeps the same local calendar offset.
|
|
813
|
+
instants.derivedFrom = obs.from;
|
|
814
|
+
instants.offsetDays = String(obs.offsetDays);
|
|
815
|
+
const dateStr = base.toISOString().slice(0, 10);
|
|
816
|
+
return {
|
|
817
|
+
id: rule.id,
|
|
818
|
+
date: dateStr,
|
|
819
|
+
instants,
|
|
820
|
+
monthLabel: monthLabelFor(dateStr, loc),
|
|
821
|
+
diagnostics,
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
break;
|
|
825
|
+
}
|
|
826
|
+
case "nakshatra-pervades": {
|
|
827
|
+
const r = resolveNakshatraPervades(obs, year, loc, diagnostics);
|
|
828
|
+
day = r.day;
|
|
829
|
+
instants = r.instants;
|
|
830
|
+
break;
|
|
831
|
+
}
|
|
832
|
+
case "weekday-relative": {
|
|
833
|
+
const baseRes = resolved?.get(obs.from);
|
|
834
|
+
if (!baseRes || !baseRes.date) {
|
|
835
|
+
diagnostics.push(`weekday-relative: referenced rule "${obs.from}" ${baseRes ? "has no date" : "not found / not yet computed"}`);
|
|
836
|
+
}
|
|
837
|
+
else {
|
|
838
|
+
// Latest `weekday` strictly before the anchor festival's date.
|
|
839
|
+
const d = new Date(`${baseRes.date}T12:00:00Z`);
|
|
840
|
+
do {
|
|
841
|
+
d.setUTCDate(d.getUTCDate() - 1);
|
|
842
|
+
} while (d.getUTCDay() !== obs.weekday);
|
|
843
|
+
const dateStr = d.toISOString().slice(0, 10);
|
|
844
|
+
instants.relativeTo = obs.from;
|
|
845
|
+
instants.anchorDate = baseRes.date;
|
|
846
|
+
return { id: rule.id, date: dateStr, instants, monthLabel: monthLabelFor(dateStr, loc), diagnostics };
|
|
847
|
+
}
|
|
848
|
+
break;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
const date = day ? localDayString(day, loc.timeZone) : "";
|
|
852
|
+
if (!date) {
|
|
853
|
+
diagnostics.push(`rule "${rule.id}" resolved to no date`);
|
|
854
|
+
}
|
|
855
|
+
return {
|
|
856
|
+
id: rule.id,
|
|
857
|
+
date,
|
|
858
|
+
instants,
|
|
859
|
+
monthLabel: date ? monthLabelFor(date, loc) : { purnimanta: rule.month?.purnimanta ?? "", amanta: "" },
|
|
860
|
+
diagnostics,
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
/** Both month labels for a civil date (anchored at local noon to avoid edges). */
|
|
864
|
+
function monthLabelFor(dateStr, loc) {
|
|
865
|
+
// Anchor at local-day midnight + 12h so we're firmly inside the day.
|
|
866
|
+
const midnight = startOfLocalDayUTC(new Date(`${dateStr}T00:00:00Z`), loc.timeZone);
|
|
867
|
+
const noonish = new Date(midnight.getTime() + DAY_MS / 2);
|
|
868
|
+
const lm = lunarMonth(noonish, { system: "purnimanta" });
|
|
869
|
+
return { purnimanta: lm.purnimantaLabel, amanta: lm.amantaLabel };
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* Compute all festivals for `(year, loc)`.
|
|
873
|
+
*
|
|
874
|
+
* Resolves non-derived rules first, then derived rules (so their references
|
|
875
|
+
* exist). Orders results ascending by date; undated results sort last.
|
|
876
|
+
* NEVER silently drops: any undated result carries a diagnostic, and a matching
|
|
877
|
+
* top-level diagnostic is emitted.
|
|
878
|
+
*/
|
|
879
|
+
export function computeFestivals(year, loc, opts = {}) {
|
|
880
|
+
validateLocation(loc); // fail fast on bad input (not per-rule)
|
|
881
|
+
const rules = opts.rules ?? [];
|
|
882
|
+
const topDiagnostics = [];
|
|
883
|
+
const resolved = new Map();
|
|
884
|
+
// Two passes: non-derived first so cross-referencing rules (derived offsets
|
|
885
|
+
// and weekday-relative anchors) can read their `from` festival's result.
|
|
886
|
+
const refsAnother = (r) => r.observance.kind === "derived" || r.observance.kind === "weekday-relative";
|
|
887
|
+
const nonDerived = rules.filter((r) => !refsAnother(r));
|
|
888
|
+
const derived = rules.filter(refsAnother);
|
|
889
|
+
for (const rule of [...nonDerived, ...derived]) {
|
|
890
|
+
// Per-rule isolation: an unexpected throw in one resolver must NOT abort the
|
|
891
|
+
// whole batch — convert it to a dated-empty result (the never-silently-drop
|
|
892
|
+
// contract), so one bad rule can't take down the rest of the calendar.
|
|
893
|
+
let res;
|
|
894
|
+
try {
|
|
895
|
+
res = computeFestival(rule, year, loc, resolved);
|
|
896
|
+
}
|
|
897
|
+
catch (e) {
|
|
898
|
+
res = {
|
|
899
|
+
id: rule.id,
|
|
900
|
+
date: "",
|
|
901
|
+
instants: {},
|
|
902
|
+
monthLabel: { purnimanta: rule.month?.purnimanta ?? "", amanta: "" },
|
|
903
|
+
diagnostics: [`rule "${rule.id}" threw during resolution: ${e.message}`],
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
resolved.set(rule.id, res);
|
|
907
|
+
}
|
|
908
|
+
const results = rules.map((r) => resolved.get(r.id));
|
|
909
|
+
// Surface every miss at the top level too (never silent).
|
|
910
|
+
for (const r of results) {
|
|
911
|
+
if (!r.date) {
|
|
912
|
+
topDiagnostics.push(`"${r.id}" produced no date: ${r.diagnostics.join("; ") || "unknown"}`);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
// Order by date; undated (empty string) last.
|
|
916
|
+
results.sort((a, b) => {
|
|
917
|
+
if (a.date && b.date)
|
|
918
|
+
return a.date < b.date ? -1 : a.date > b.date ? 1 : 0;
|
|
919
|
+
if (a.date)
|
|
920
|
+
return -1;
|
|
921
|
+
if (b.date)
|
|
922
|
+
return 1;
|
|
923
|
+
return 0;
|
|
924
|
+
});
|
|
925
|
+
return { results, diagnostics: topDiagnostics };
|
|
926
|
+
}
|
|
927
|
+
//# sourceMappingURL=festivals.js.map
|