pray-calc/src/getMSC.ts
Aric Camarata 8f39fcd82e refactor: code quality improvements across the board
- 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
2026-03-08 11:10:22 -04:00

202 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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);
}