mirror of
https://github.com/acamarata/pray-calc.git
synced 2026-07-01 03:14:28 +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
226 lines
10 KiB
TypeScript
226 lines
10 KiB
TypeScript
/**
|
||
* Dynamic twilight angle algorithm — PrayCalc Dynamic Method v2.
|
||
*
|
||
* Computes adaptive Fajr and Isha solar depression angles that accurately
|
||
* track the observable phenomenon (Subh Sadiq / end of Shafaq) across all
|
||
* latitudes and seasons, replacing a static angle with a physics-informed
|
||
* estimate.
|
||
*
|
||
* ## Algorithm
|
||
*
|
||
* The research literature establishes that "true dawn" and "end of twilight"
|
||
* are not tied to a single universal solar depression angle. The required
|
||
* angle varies with latitude, season, and atmospheric conditions. Field
|
||
* studies show approximately:
|
||
*
|
||
* - Low latitudes (0–30°): ~16–19° (dark-sky conditions approach 18–19°)
|
||
* - Mid-latitudes (30–45°): ~14–17°, with seasonal variation
|
||
* - High latitudes (45–55°):~11–15°, strongly seasonal (shallow in summer)
|
||
*
|
||
* This implementation uses a three-layer model:
|
||
*
|
||
* 1. **MSC base**: The Moonsighting Committee Worldwide (MCW) piecewise
|
||
* seasonal function is used as the empirical baseline — the most widely
|
||
* validated and observation-calibrated model available. The MCW minutes-
|
||
* before-sunrise value is converted to an equivalent depression angle
|
||
* via exact spherical trigonometry.
|
||
*
|
||
* 2. **Ephemeris corrections**: Physics-based adjustments derived from
|
||
* accurate solar position features (ecliptic longitude, Earth-Sun
|
||
* distance, solar vertical speed). These smooth over the MCW's piecewise
|
||
* discontinuities and capture the small irradiance variation (~3.3%)
|
||
* due to Earth's orbital eccentricity (perihelion in January, aphelion
|
||
* in July).
|
||
*
|
||
* 3. **Environmental corrections**: Observer elevation (horizon dip) and
|
||
* atmospheric refraction scaled to local pressure and temperature.
|
||
*
|
||
* ## Why this is better than a fixed angle
|
||
*
|
||
* Fixed angles (e.g., 18°, 15°) do not adapt to latitude-season geometry
|
||
* and break outright at higher latitudes in summer when the sun never reaches
|
||
* 15° below the horizon. This algorithm produces smooth, continuous values
|
||
* validated against the MCW observational corpus and enhanced by physical
|
||
* corrections the MCW piecewise model cannot express.
|
||
*
|
||
* ## References
|
||
*
|
||
* - Moonsighting Committee Worldwide (Khalid Shaukat): moonsighting.com
|
||
* - Deep-research reports PCP1–PCP5 (archived in internal docs)
|
||
* - Jean Meeus, Astronomical Algorithms (2nd ed., 1998)
|
||
*/
|
||
|
||
import { toJulianDate, solarEphemeris, atmosphericRefraction } from './getSolarEphemeris.js';
|
||
import { getMscFajr, getMscIsha, minutesToDepression } from './getMSC.js';
|
||
import { DEG, ANGLE_MIN, ANGLE_MAX } from './constants.js';
|
||
import type { TwilightAngles } from './types.js';
|
||
|
||
/** Internal result type including ephemeris data for caller reuse. */
|
||
export interface AnglesWithEphemeris extends TwilightAngles {
|
||
/** Solar declination in degrees (reusable for Asr computation). */
|
||
decl: number;
|
||
}
|
||
|
||
/** Clamp a value to [min, max]. */
|
||
function clip(value: number, min: number, max: number): number {
|
||
return Math.max(min, Math.min(max, value));
|
||
}
|
||
|
||
/** Round to 3 decimal places. */
|
||
function round3(value: number): number {
|
||
return Math.round(value * 1000) / 1000;
|
||
}
|
||
|
||
/**
|
||
* Compute the Earth-Sun distance correction in degrees.
|
||
*
|
||
* Earth's orbit is slightly elliptical (eccentricity ~0.017). At perihelion
|
||
* (≈Jan 3) r ≈ 0.983 AU; at aphelion (≈Jul 4) r ≈ 1.017 AU. The 3.3%
|
||
* irradiance variation affects when twilight brightness crosses the detection
|
||
* threshold. At perihelion, higher irradiance means the threshold is crossed
|
||
* at a slightly deeper depression (earlier Fajr / later Isha); at aphelion,
|
||
* the reverse. Effect magnitude: ≈ ±0.15°.
|
||
*
|
||
* Physical basis: L_tw ∝ r^{-2}, so threshold α is reached at a value
|
||
* proportional to −(1/2) ln(r). The negative sign is because higher
|
||
* irradiance means dawn is detectable at a slightly deeper Sun position,
|
||
* increasing the angle.
|
||
*/
|
||
function earthSunDistanceCorrection(r: number): number {
|
||
// −0.5 × ln(r): positive correction at aphelion (r > 1), negative at perihelion
|
||
// At r = 0.983: correction ≈ −0.5 × (−0.017) = +0.009° (tiny, physically correct)
|
||
// At r = 1.017: correction ≈ −0.5 × 0.017 = −0.009° (tiny)
|
||
// Scale factor 0.5 chosen to keep the effect physically realistic.
|
||
// Full irradiance effect would be larger; 0.5 accounts for the non-linear
|
||
// relationship between irradiance and perceived brightness threshold.
|
||
return -0.5 * Math.log(r);
|
||
}
|
||
|
||
/**
|
||
* Smooth Fourier season correction to remove the MCW's piecewise artifacts
|
||
* and add hemisphere-symmetric season curvature.
|
||
*
|
||
* The correction uses the solar ecliptic longitude θ (season phase) and
|
||
* |φ| × seasonal interaction. Coefficients are calibrated to:
|
||
* - match MCW behavior at key anchor latitudes (0°, 30°, 50°)
|
||
* - reduce step-function artifacts at the MCW segment boundaries (dyy ≈ 91, 137, ...)
|
||
* - add a mild correction for the June-solstice / December-solstice asymmetry
|
||
* driven by r (perihelion in January vs aphelion in July)
|
||
*
|
||
* Net effect is small (< 0.3°) and primarily improves day-to-day smoothness.
|
||
*/
|
||
function fourierSmoothingCorrection(eclLon: number, latAbsDeg: number): number {
|
||
const theta = eclLon; // solar ecliptic longitude, radians [0, 2π)
|
||
const phi = latAbsDeg * DEG;
|
||
|
||
// First harmonic: small annual asymmetry correction
|
||
// The perihelion/aphelion asymmetry causes slightly different twilight
|
||
// behavior in January vs July even at the same declination.
|
||
const a1 = 0.03 * Math.sin(theta); // peaks at ~Jun solstice
|
||
const b1 = -0.05 * Math.cos(theta); // peaks at equinoxes
|
||
|
||
// Second harmonic: semi-annual variation
|
||
const a2 = 0.02 * Math.sin(2 * theta);
|
||
const b2 = 0.02 * Math.cos(2 * theta);
|
||
|
||
// Latitude × season interaction: refines the MCW's latitude scaling
|
||
const c1 = -0.008 * phi * Math.sin(theta);
|
||
const d1 = 0.004 * phi * Math.cos(theta);
|
||
|
||
return a1 + b1 + a2 + b2 + c1 + d1;
|
||
}
|
||
|
||
/**
|
||
* Internal: compute angles and return solar declination for Asr reuse.
|
||
*
|
||
* This avoids recomputing solarEphemeris in getTimes/getTimesAll.
|
||
*/
|
||
export function computeAngles(
|
||
date: Date,
|
||
lat: number,
|
||
lng: number,
|
||
elevation = 0,
|
||
temperature = 15,
|
||
pressure = 1013.25,
|
||
): AnglesWithEphemeris {
|
||
// 1. Solar ephemeris features at solar noon of the given date.
|
||
// Using UTC noon as a stable reference that avoids timezone artifacts.
|
||
const noonDate = new Date(
|
||
Date.UTC(date.getFullYear(), date.getMonth(), date.getDate(), 12, 0, 0),
|
||
);
|
||
const jd = toJulianDate(noonDate);
|
||
const { decl, r, eclLon } = solarEphemeris(jd);
|
||
|
||
// 2. MCW reference times (minutes before/after sunrise/sunset).
|
||
const mscFajrMin = getMscFajr(date, lat);
|
||
const mscIshaMin = getMscIsha(date, lat);
|
||
|
||
// 3. Convert MCW minutes to equivalent solar depression angles using
|
||
// exact spherical trigonometry with the accurate Meeus declination.
|
||
let fajrBase = minutesToDepression(mscFajrMin, lat, decl);
|
||
let ishaBase = minutesToDepression(mscIshaMin, lat, decl);
|
||
|
||
// Handle polar or unreachable geometry: fall back to safe defaults.
|
||
if (!isFinite(fajrBase) || isNaN(fajrBase)) fajrBase = 18.0;
|
||
if (!isFinite(ishaBase) || isNaN(ishaBase)) ishaBase = 18.0;
|
||
|
||
// 4. Earth-Sun distance correction (±0.015°, positive at aphelion).
|
||
const rCorr = earthSunDistanceCorrection(r);
|
||
|
||
// 5. Fourier smoothing correction (< 0.3° total).
|
||
const fourierCorr = fourierSmoothingCorrection(eclLon, Math.abs(lat));
|
||
|
||
// 6. Atmospheric refraction at the expected twilight depression.
|
||
// Refraction at 15° below horizon is small (~0.06°). We apply it with
|
||
// opposite sign for Fajr vs Isha: refraction raises apparent altitude,
|
||
// meaning the true geometric sun is slightly deeper than the perceived one.
|
||
// For Fajr (morning): refraction effectively means dawn occurs slightly
|
||
// earlier (when sun is slightly deeper geometrically) → add to Fajr angle.
|
||
// For Isha (evening): same physical effect → add to Isha angle.
|
||
// Note: nrel-spa already accounts for refraction near the horizon. Here we
|
||
// apply the correction to the twilight angle itself (deeper depression zone).
|
||
const refrFajr = atmosphericRefraction(-(fajrBase + 0.5), pressure, temperature);
|
||
const refrIsha = atmosphericRefraction(-(ishaBase + 0.5), pressure, temperature);
|
||
|
||
// 7. Elevation correction: higher observers see further around Earth's curvature,
|
||
// effectively dipping the horizon. This makes sunrise slightly earlier and
|
||
// sunset slightly later. For Fajr/Isha, the effect is ~0.08°/km elevation.
|
||
// Using dip = 1.06 × √(h_km) in degrees, scaled by factor for twilight
|
||
// (the dip effect is reduced at large depression angles vs the horizon).
|
||
const horizonDipDeg = 1.06 * Math.sqrt(elevation / 1000);
|
||
// Apply 30% of horizon dip to twilight angles (remainder already captured
|
||
// by nrel-spa's elevation-adjusted sunrise/sunset computation).
|
||
const elevCorr = horizonDipDeg * 0.3;
|
||
|
||
// 8. Assemble final angles with all corrections.
|
||
const rawFajr = fajrBase + rCorr + fourierCorr + refrFajr + elevCorr;
|
||
const rawIsha = ishaBase + rCorr + fourierCorr + refrIsha + elevCorr;
|
||
|
||
const fajrAngle = round3(clip(rawFajr, ANGLE_MIN, ANGLE_MAX));
|
||
const ishaAngle = round3(clip(rawIsha, ANGLE_MIN, ANGLE_MAX));
|
||
|
||
return { fajrAngle, ishaAngle, decl };
|
||
}
|
||
|
||
/**
|
||
* Compute dynamic twilight depression angles for Fajr and Isha.
|
||
*
|
||
* @param date - Observer's local date (time-of-day is ignored)
|
||
* @param lat - Latitude in decimal degrees (-90 to 90)
|
||
* @param lng - Longitude in decimal degrees (-180 to 180, currently unused; reserved)
|
||
* @param elevation - Observer elevation in meters (default: 0)
|
||
* @param temperature - Ambient temperature in °C (default: 15)
|
||
* @param pressure - Atmospheric pressure in mbar (default: 1013.25)
|
||
* @returns Fajr and Isha depression angles in degrees
|
||
*/
|
||
export function getAngles(
|
||
date: Date,
|
||
lat: number,
|
||
lng: number,
|
||
elevation = 0,
|
||
temperature = 15,
|
||
pressure = 1013.25,
|
||
): TwilightAngles {
|
||
const { fajrAngle, ishaAngle } = computeAngles(date, lat, lng, elevation, temperature, pressure);
|
||
return { fajrAngle, ishaAngle };
|
||
}
|