pray-calc/src/getAngles.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

226 lines
10 KiB
TypeScript
Raw Permalink 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.

/**
* 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 (030°): ~1619° (dark-sky conditions approach 1819°)
* - Mid-latitudes (3045°): ~1417°, with seasonal variation
* - High latitudes (4555°):~1115°, 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 PCP1PCP5 (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 };
}