mirror of
https://github.com/acamarata/pray-calc.git
synced 2026-06-30 19:04:26 +00:00
- Extract magic numbers into named constants (DHUHR_OFFSET_MINUTES, ANGLE_MIN/MAX, LAT_SCALE) with source citations for MCW coefficients - Add input validation (RangeError) for lat, lng, tz, elevation on all public API functions (getTimes, getTimesAll) - Optimize solar ephemeris: computeAngles() returns declination so getTimes/getTimesAll reuse it for Asr instead of computing twice - DRY: shared constants.ts for DEG, Dhuhr offset, angle bounds - Improve MethodEntry type with labeled tuple elements and NaN docs - Add stricter tsconfig (noImplicitReturns, noFallthroughCasesInSwitch) - Switch tests to node:test framework (TAP output, describe/it blocks) - Add 8 new input validation tests (104 ESM + 13 CJS total) - Add ESLint + Prettier with CI lint job - Remove src/ from npm package files (smaller published tarball) - Document NaN return behavior in JSDoc for getTimes/getTimesAll
202 lines
7.1 KiB
TypeScript
202 lines
7.1 KiB
TypeScript
/**
|
||
* Moonsighting Committee Worldwide (MCW) seasonal algorithm.
|
||
*
|
||
* Computes Fajr and Isha as time offsets from sunrise/sunset using the
|
||
* empirical piecewise-linear seasonal functions developed by the Moonsighting
|
||
* Committee Worldwide (Khalid Shaukat). The functions were derived by
|
||
* curve-fitting observations of Subh Sadiq (true dawn) and the end of
|
||
* Shafaq (twilight glow) across multiple latitudes.
|
||
*
|
||
* Reference: moonsighting.com/isha_fajr.html
|
||
*
|
||
* ## MCW Coefficient Key
|
||
*
|
||
* The piecewise-linear anchor values (a, b, c, d) follow the pattern:
|
||
* value = BASE + (SLOPE / LAT_SCALE) × |latitude|
|
||
*
|
||
* where BASE is the equatorial offset in minutes, SLOPE is the per-degree
|
||
* latitude coefficient, and LAT_SCALE = 55° is the normalisation latitude.
|
||
* Coefficients were curve-fit to multi-latitude observations of Subh Sadiq
|
||
* and Shafaq by the Moonsighting Committee (moonsighting.com/isha_fajr.html).
|
||
*
|
||
* High-latitude handling (|lat| > 55°): falls back to 1/7-night rule.
|
||
*/
|
||
|
||
export type ShafaqMode = 'general' | 'ahmer' | 'abyad';
|
||
|
||
/**
|
||
* Normalisation latitude (degrees) used as the divisor in MCW latitude
|
||
* scaling coefficients. All MCW slope values are expressed per 55° of
|
||
* latitude so that the piecewise function smoothly scales from equator
|
||
* to mid-high latitudes.
|
||
*/
|
||
const LAT_SCALE = 55;
|
||
|
||
function isLeapYear(year: number): boolean {
|
||
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
||
}
|
||
|
||
/**
|
||
* Compute the MCW seasonal index (dyy): days elapsed since the nearest
|
||
* winter solstice (Northern Hemisphere) or summer solstice (Southern).
|
||
*/
|
||
function computeDyy(date: Date, latitude: number): { dyy: number; daysInYear: number } {
|
||
const year = date.getFullYear();
|
||
const daysInYear = isLeapYear(year) ? 366 : 365;
|
||
|
||
// Reference solstice: Dec 21 for Northern, Jun 21 for Southern
|
||
const refMonth = latitude >= 0 ? 11 : 5; // Dec = 11, Jun = 5
|
||
const refDay = 21;
|
||
|
||
const zeroDate = new Date(year, refMonth, refDay);
|
||
let diffDays = Math.floor(
|
||
(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) -
|
||
Date.UTC(zeroDate.getFullYear(), zeroDate.getMonth(), zeroDate.getDate())) /
|
||
86400000,
|
||
);
|
||
if (diffDays < 0) diffDays += daysInYear;
|
||
|
||
return { dyy: diffDays, daysInYear };
|
||
}
|
||
|
||
/**
|
||
* Piecewise-linear seasonal interpolation over 6 segments.
|
||
* a, b, c, d are the reference values at the seasonal anchor points.
|
||
*/
|
||
function interpolateSegment(
|
||
dyy: number,
|
||
daysInYear: number,
|
||
a: number,
|
||
b: number,
|
||
c: number,
|
||
d: number,
|
||
): number {
|
||
if (dyy < 91) {
|
||
return a + ((b - a) / 91) * dyy;
|
||
} else if (dyy < 137) {
|
||
return b + ((c - b) / 46) * (dyy - 91);
|
||
} else if (dyy < 183) {
|
||
return c + ((d - c) / 46) * (dyy - 137);
|
||
} else if (dyy < 229) {
|
||
return d + ((c - d) / 46) * (dyy - 183);
|
||
} else if (dyy < 275) {
|
||
return c + ((b - c) / 46) * (dyy - 229);
|
||
} else {
|
||
const len = daysInYear - 275;
|
||
return b + ((a - b) / len) * (dyy - 275);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Compute Fajr offset in minutes before sunrise using the MCW algorithm.
|
||
*
|
||
* Returns minutes before sunrise. At latitudes above 55°, the 1/7-night
|
||
* approximation is recommended (handled at the calling site).
|
||
*/
|
||
export function getMscFajr(date: Date, latitude: number): number {
|
||
const latAbs = Math.abs(latitude);
|
||
const { dyy, daysInYear } = computeDyy(date, latitude);
|
||
|
||
// Anchor values: BASE + (SLOPE / LAT_SCALE) × |lat|
|
||
// BASE = 75 min (equatorial Fajr offset). Slopes from MCW curve-fit.
|
||
const a = 75 + (28.65 / LAT_SCALE) * latAbs;
|
||
const b = 75 + (19.44 / LAT_SCALE) * latAbs;
|
||
const c = 75 + (32.74 / LAT_SCALE) * latAbs;
|
||
const d = 75 + (48.1 / LAT_SCALE) * latAbs;
|
||
|
||
return Math.round(interpolateSegment(dyy, daysInYear, a, b, c, d));
|
||
}
|
||
|
||
/**
|
||
* Compute Isha offset in minutes after sunset using the MCW algorithm.
|
||
*
|
||
* Three Shafaq modes:
|
||
* - 'general': blend that reduces hardship at high latitudes (default)
|
||
* - 'ahmer': based on disappearance of redness (shafaq ahmer)
|
||
* - 'abyad': based on disappearance of whiteness (shafaq abyad), later
|
||
*/
|
||
export function getMscIsha(date: Date, latitude: number, shafaq: ShafaqMode = 'general'): number {
|
||
const latAbs = Math.abs(latitude);
|
||
const { dyy, daysInYear } = computeDyy(date, latitude);
|
||
|
||
let a: number, b: number, c: number, d: number;
|
||
|
||
switch (shafaq) {
|
||
case 'ahmer':
|
||
// Shafaq ahmer (red glow): BASE = 62 min (shorter twilight)
|
||
a = 62 + (17.4 / LAT_SCALE) * latAbs;
|
||
b = 62 - (7.16 / LAT_SCALE) * latAbs;
|
||
c = 62 + (5.12 / LAT_SCALE) * latAbs;
|
||
d = 62 + (19.44 / LAT_SCALE) * latAbs;
|
||
break;
|
||
case 'abyad':
|
||
// Shafaq abyad (white glow): BASE = 75 min (longer twilight)
|
||
a = 75 + (25.6 / LAT_SCALE) * latAbs;
|
||
b = 75 + (7.16 / LAT_SCALE) * latAbs;
|
||
c = 75 + (36.84 / LAT_SCALE) * latAbs;
|
||
d = 75 + (81.84 / LAT_SCALE) * latAbs;
|
||
break;
|
||
default: // 'general'
|
||
// General (blended) mode: BASE = 75 min
|
||
a = 75 + (25.6 / LAT_SCALE) * latAbs;
|
||
b = 75 + (2.05 / LAT_SCALE) * latAbs;
|
||
c = 75 - (9.21 / LAT_SCALE) * latAbs;
|
||
d = 75 + (6.14 / LAT_SCALE) * latAbs;
|
||
}
|
||
|
||
return Math.round(interpolateSegment(dyy, daysInYear, a, b, c, d));
|
||
}
|
||
|
||
/**
|
||
* Convert MCW minutes-before-sunrise to an equivalent solar depression angle
|
||
* in degrees, using exact spherical trigonometry.
|
||
*
|
||
* This is the inverse of the standard hour-angle sunrise formula and gives
|
||
* the depression angle that corresponds to a given pre-sunrise interval at
|
||
* the observer's latitude and the given solar declination.
|
||
*
|
||
* Returns NaN if the geometry is unreachable (polar day/night).
|
||
*/
|
||
export function minutesToDepression(minutes: number, latDeg: number, declDeg: number): number {
|
||
const phi = latDeg * (Math.PI / 180);
|
||
const delta = declDeg * (Math.PI / 180);
|
||
|
||
const cosPhi = Math.cos(phi);
|
||
const sinPhi = Math.sin(phi);
|
||
const cosDelta = Math.cos(delta);
|
||
const sinDelta = Math.sin(delta);
|
||
|
||
// Standard sunrise/sunset: h = -0.833° (includes refraction + semi-diameter)
|
||
const h0 = -0.833 * (Math.PI / 180);
|
||
const sinH0 = Math.sin(h0);
|
||
|
||
const denominator = cosPhi * cosDelta;
|
||
if (Math.abs(denominator) < 1e-10) return NaN;
|
||
|
||
// Hour angle at standard sunrise
|
||
const cosH_rise = (sinH0 - sinPhi * sinDelta) / denominator;
|
||
|
||
if (cosH_rise < -1) return NaN; // polar night
|
||
if (cosH_rise > 1) return NaN; // polar day
|
||
|
||
const H_rise = Math.acos(cosH_rise); // radians
|
||
|
||
// Hour angle at the prayer time (further from solar noon)
|
||
const deltaH = (minutes / 60) * 15 * (Math.PI / 180);
|
||
const H_prayer = H_rise + deltaH;
|
||
|
||
// Cap at π (midnight) - sun cannot go further below horizon
|
||
if (H_prayer > Math.PI) {
|
||
// Return the depression at midnight (minimum possible for this date/lat)
|
||
const sinH_midnight = sinPhi * sinDelta + cosPhi * cosDelta * Math.cos(Math.PI);
|
||
const h_midnight = Math.asin(Math.max(-1, Math.min(1, sinH_midnight)));
|
||
return -h_midnight / (Math.PI / 180);
|
||
}
|
||
|
||
// Solar altitude at H_prayer
|
||
const sinH_prayer = sinPhi * sinDelta + cosPhi * cosDelta * Math.cos(H_prayer);
|
||
const h_prayer = Math.asin(Math.max(-1, Math.min(1, sinH_prayer)));
|
||
|
||
// Depression angle: positive when sun is below horizon
|
||
return -h_prayer / (Math.PI / 180);
|
||
}
|