hijri-core/src/engines/fcna.ts
Aric Camarata 6caa9eed2c ci: fix eslint config, add missing ts-eslint devDeps, format src
- Add @typescript-eslint/parser and @typescript-eslint/eslint-plugin to
  devDependencies (required by eslint.config.mjs direct imports and by
  @acamarata/eslint-config peerDependencies)
- Fix eslint.config.mjs: scope files to src/**/*.ts, add parserOptions.project
  for type-aware rules, expand ignores to cover coverage/ and docs/
- Run prettier --write src/ to fix format:check failures
2026-05-31 08:47:31 -04:00

279 lines
11 KiB
TypeScript

// FCNA engine: Fiqh Council of North America / ISNA calendar.
//
// The FCNA criterion: if the new moon conjunction occurs before 12:00 noon UTC
// on day D, the new Hijri month begins at midnight starting day D+1. If at or
// after 12:00 UTC, the month begins at midnight starting day D+2.
//
// New moon times come from Jean Meeus, Astronomical Algorithms (2nd ed.),
// Chapter 49, accurate to within a few minutes for 1000-3000 CE.
import { hDatesTable } from "../data/hDates";
import { MS_PER_DAY, MONTHS_PER_YEAR } from "../constants";
import type { CalendarEngine, HijriDate } from "../types";
// ─── Constants ───────────────────────────────────────────────────────────────
const SYNODIC = 29.530588861; // Mean synodic month (days)
const JDE0 = 2451550.09766; // Meeus k=0 (2nd ed. Ch.49: 2451550.09765; 0.864 s diff, within tolerance)
const JDE_UNIX = 2440587.5; // JDE of Unix epoch 1970-01-01 00:00 UTC
const TO_RAD = Math.PI / 180;
// Approximate k index of 1 Muharram 1 AH in Meeus numbering.
// Islamic epoch JDE ~1948438.5 -> k ~= (1948438.5 - JDE0) / SYNODIC ~= -17037.
const K_EPOCH = -17037;
// ─── Meeus Chapter 49: corrected new moon JDE ────────────────────────────────
function newMoonJDE(k: number): number {
const T = k / 1236.85;
const T2 = T * T;
const T3 = T2 * T;
const T4 = T3 * T;
let jde = JDE0 + SYNODIC * k + 0.00015437 * T2 - 0.00000015 * T3 + 0.00000000073 * T4;
const M = (2.5534 + 29.1053567 * k - 0.0000014 * T2 - 0.00000011 * T3) % 360;
const Mprime =
(201.5643 + 385.81693528 * k + 0.0107582 * T2 + 0.00001238 * T3 - 0.000000058 * T4) % 360;
const F =
(160.7108 + 390.67050284 * k - 0.0016118 * T2 - 0.00000227 * T3 + 0.000000011 * T4) % 360;
const Omega = (124.7746 - 1.56375588 * k + 0.0020672 * T2 + 0.00000215 * T3) % 360;
const E = 1 - 0.002516 * T - 0.0000074 * T2;
const E2 = E * E;
const Mrad = M * TO_RAD;
const Mprad = Mprime * TO_RAD;
const Frad = F * TO_RAD;
const Orad = Omega * TO_RAD;
jde +=
-0.4072 * Math.sin(Mprad) +
0.17241 * E * Math.sin(Mrad) +
0.01608 * Math.sin(2 * Mprad) +
0.01039 * Math.sin(2 * Frad) +
0.00739 * E * Math.sin(Mprad - Mrad) -
0.00514 * E * Math.sin(Mprad + Mrad) +
0.00208 * E2 * Math.sin(2 * Mrad) -
0.00111 * Math.sin(Mprad - 2 * Frad) -
0.00057 * Math.sin(Mprad + 2 * Frad) +
0.00056 * E * Math.sin(2 * Mprad + Mrad) -
0.00042 * Math.sin(3 * Mprad) +
0.00042 * E * Math.sin(Mrad + 2 * Frad) +
0.00038 * E * Math.sin(Mrad - 2 * Frad) -
0.00024 * E * Math.sin(2 * Mprad - Mrad) -
0.00017 * Math.sin(Orad) -
0.00007 * Math.sin(Mprad + 2 * Mrad) +
0.00004 * Math.sin(2 * Mprad - 2 * Frad) +
0.00004 * Math.sin(3 * Mrad) +
0.00003 * Math.sin(Mprad + Mrad - 2 * Frad) +
0.00003 * Math.sin(2 * Mprad + 2 * Frad) -
0.00003 * Math.sin(Mprad + Mrad + 2 * Frad) +
0.00003 * Math.sin(Mprad - Mrad + 2 * Frad) -
0.00002 * Math.sin(Mprad - Mrad - 2 * Frad) -
0.00002 * Math.sin(3 * Mprad + Mrad) +
0.00002 * Math.sin(4 * Mprad);
const A1 = (299.77 + 0.107408 * k - 0.009173 * T2) * TO_RAD;
const A2 = (251.88 + 0.016321 * k) * TO_RAD;
const A3 = (251.83 + 26.651886 * k) * TO_RAD;
const A4 = (349.42 + 36.412478 * k) * TO_RAD;
const A5 = (84.66 + 18.206239 * k) * TO_RAD;
const A6 = (141.74 + 53.303771 * k) * TO_RAD;
const A7 = (207.14 + 2.453732 * k) * TO_RAD;
const A8 = (154.84 + 7.30686 * k) * TO_RAD;
const A9 = (34.52 + 27.261239 * k) * TO_RAD;
const A10 = (207.19 + 0.121824 * k) * TO_RAD;
const A11 = (291.34 + 1.844379 * k) * TO_RAD;
const A12 = (161.72 + 24.198154 * k) * TO_RAD;
const A13 = (239.56 + 25.513099 * k) * TO_RAD;
const A14 = (331.55 + 3.592518 * k) * TO_RAD;
jde +=
+0.000325 * Math.sin(A1) +
0.000165 * Math.sin(A2) +
0.000164 * Math.sin(A3) +
0.000126 * Math.sin(A4) +
0.00011 * Math.sin(A5) +
0.000062 * Math.sin(A6) +
0.00006 * Math.sin(A7) +
0.000056 * Math.sin(A8) +
0.000047 * Math.sin(A9) +
0.000042 * Math.sin(A10) +
0.00004 * Math.sin(A11) +
0.000037 * Math.sin(A12) +
0.000035 * Math.sin(A13) +
0.000023 * Math.sin(A14);
return jde;
}
// ─── JDE / UTC conversion ─────────────────────────────────────────────────────
function jdeToUtcMs(jde: number): number {
return (jde - JDE_UNIX) * MS_PER_DAY;
}
function utcMsToKApprox(ms: number): number {
const jde = ms / MS_PER_DAY + JDE_UNIX;
return (jde - JDE0) / SYNODIC;
}
// ─── Find nearest corrected new moon ─────────────────────────────────────────
// Searches k0-2 through k0+2 to handle any estimation error.
function nearestNewMoonMs(anchorMs: number): number {
const k0 = Math.round(utcMsToKApprox(anchorMs));
let bestMs = 0;
let bestDist = Infinity;
for (let k = k0 - 2; k <= k0 + 2; k++) {
const ms = jdeToUtcMs(newMoonJDE(k));
const dist = Math.abs(ms - anchorMs);
if (dist < bestDist) {
bestDist = dist;
bestMs = ms;
}
}
return bestMs;
}
// ─── FCNA criterion ──────────────────────────────────────────────────────────
// Returns the midnight UTC ms that starts the new FCNA Hijri month.
function fcnaCriterionMs(conjMs: number): number {
const midnight = Math.floor(conjMs / MS_PER_DAY) * MS_PER_DAY;
const noon = midnight + 12 * 3_600_000;
return conjMs < noon ? midnight + MS_PER_DAY : midnight + 2 * MS_PER_DAY;
}
// ─── UAQ anchor ──────────────────────────────────────────────────────────────
// Returns the UTC ms of the UAQ month start for (hy, hm).
// In-range years (1318-1500 H): binary-search table, sum dpm day counts.
// Out-of-range years: estimate from Islamic epoch + mean synodic month count.
function uaqAnchorMs(hy: number, hm: number): number {
let lo = 0,
hi = hDatesTable.length - 1,
found = -1;
while (lo <= hi) {
const mid = (lo + hi) >>> 1;
// mid is always within [0, hDatesTable.length-1] by binary search invariant
const midHy = hDatesTable[mid]!.hy;
if (midHy === hy) {
found = mid;
break;
} else if (midHy < hy) lo = mid + 1;
else hi = mid - 1;
}
// found is within [0, hDatesTable.length-1]; guard confirms it's valid before use.
if (found !== -1 && hDatesTable[found]!.dpm !== 0) {
const r = hDatesTable[found]!;
let days = 0;
for (let i = 0; i < hm - 1; i++) {
days += (r.dpm >> i) & 1 ? 30 : 29;
}
return Date.UTC(r.gy, r.gm - 1, r.gd) + days * MS_PER_DAY;
}
const monthsFromEpoch = (hy - 1) * MONTHS_PER_YEAR + (hm - 1);
const kApprox = K_EPOCH + monthsFromEpoch;
return jdeToUtcMs(newMoonJDE(kApprox));
}
// ─── FCNA month start ─────────────────────────────────────────────────────────
function fcnaMonthStartMs(hy: number, hm: number): number {
const anchor = uaqAnchorMs(hy, hm);
const conjMs = nearestNewMoonMs(anchor);
return fcnaCriterionMs(conjMs);
}
// ─── FCNA month length ───────────────────────────────────────────────────────
function fcnaDaysInMonth(hy: number, hm: number): number {
if (hm < 1 || hm > MONTHS_PER_YEAR) {
throw new RangeError(`month must be 1-12, got ${hm}`);
}
const thisStart = fcnaMonthStartMs(hy, hm);
const nextHy = hm < MONTHS_PER_YEAR ? hy : hy + 1;
const nextHm = hm < MONTHS_PER_YEAR ? hm + 1 : 1;
const nextStart = fcnaMonthStartMs(nextHy, nextHm);
return Math.round((nextStart - thisStart) / MS_PER_DAY);
}
// ─── FCNA Gregorian -> Hijri ──────────────────────────────────────────────────
function fcnaToHijri(gregorianDate: Date): HijriDate | null {
if (!(gregorianDate instanceof Date) || isNaN(gregorianDate.getTime())) {
throw new Error("Invalid Gregorian date");
}
// FCNA criterion is UTC-based, so UTC date components ensure correct round-trips.
const inputMs = Date.UTC(
gregorianDate.getUTCFullYear(),
gregorianDate.getUTCMonth(),
gregorianDate.getUTCDate(),
);
const kApprox = utcMsToKApprox(inputMs - 15 * MS_PER_DAY);
const k0 = Math.floor(kApprox);
for (let ki = k0 - 1; ki <= k0 + 1; ki++) {
const conjMs = jdeToUtcMs(newMoonJDE(ki));
const monthStart = fcnaCriterionMs(conjMs);
if (monthStart > inputMs) continue;
const nextConjMs = jdeToUtcMs(newMoonJDE(ki + 1));
const nextMonthStart = fcnaCriterionMs(nextConjMs);
if (inputMs < nextMonthStart) {
const monthsFromEpoch = ki - K_EPOCH;
let hy = Math.floor(monthsFromEpoch / MONTHS_PER_YEAR) + 1;
let hm = (monthsFromEpoch % MONTHS_PER_YEAR) + 1;
if (hm <= 0) {
hm += MONTHS_PER_YEAR;
hy--;
}
if (hy < 1) return null;
const hd = Math.round((inputMs - monthStart) / MS_PER_DAY) + 1;
return { hy, hm, hd };
}
}
return null;
}
// ─── FCNA Hijri -> Gregorian ──────────────────────────────────────────────────
function fcnaToGregorian(hy: number, hm: number, hd: number): Date | null {
if (hy < 1 || hm < 1 || hm > MONTHS_PER_YEAR || hd < 1) return null;
const days = fcnaDaysInMonth(hy, hm);
if (hd > days) return null;
const startMs = fcnaMonthStartMs(hy, hm);
return new Date(startMs + (hd - 1) * MS_PER_DAY);
}
// ─── FCNA validation ─────────────────────────────────────────────────────────
function fcnaIsValid(hy: number, hm: number, hd: number): boolean {
if (hy < 1 || hm < 1 || hm > MONTHS_PER_YEAR || hd < 1) return false;
return hd <= fcnaDaysInMonth(hy, hm);
}
// ─── Engine export ────────────────────────────────────────────────────────────
export const fcnaEngine: CalendarEngine = {
id: "fcna",
toHijri: fcnaToHijri,
toGregorian: fcnaToGregorian,
isValid: fcnaIsValid,
daysInMonth: fcnaDaysInMonth,
};