feat: improve Asr solver, dynamic twilight angles, and MSC algorithm

This commit is contained in:
acamarata 2025-05-04 08:59:31 -04:00
parent eb68c97b42
commit b5ee3ca8b8
7 changed files with 264 additions and 124 deletions

10
.npmignore Normal file
View file

@ -0,0 +1,10 @@
# Ignore Git history
.git
# Ignore local dev/test files
/tests/
/bin/
**/*.c
**/*.h
getMoonVisibility.js
getMSC.js
test*.js

View file

@ -1,27 +1,63 @@
// getAngles.js
'use strict';
const PI = Math.PI;
/**
* Calculate dynamic Fajr and Isha depression angles based on the current date (season)
* plus refraction and elevation adjustments.
*
* @param {number} [elevation=0] Observer elevation in meters.
* @param {number} [pressure=1013.25] Atmospheric pressure in mbar.
* @param {number} [temperature=15] Temperature in °C.
* @returns {{ fajrAngle: number, ishaAngle: number }} Depression angles in degrees.
*/
function getAngles(elevation = 0, pressure = 1013.25, temperature = 15) {
const baseAngle = 18; // Base angle for astronomical twilight
// 1) Compute day of year (1365/366)
const today = new Date();
const start = Date.UTC(today.getFullYear(), 0, 0);
const diffMs = today - start;
const dayOfYear = Math.floor(diffMs / 86400000);
// Calculate refraction adjusted angle at the horizon (altitude = 0)
const refraction = calculateAtmosphericRefraction(0, pressure, temperature);
// 2) Approximate solar declination δ (radians):
// δ = 23.44° * sin(2π * (day + 284) / 365)
const declRad = (23.44 * Math.sin(2 * PI * (dayOfYear + 284) / 365)) * (PI / 180);
// Elevation adjustment (approximate)
const elevationAdjustment = elevation / 1000 * 0.1; // Rough estimate
// 3) Seasonal factor (1 to +1)
const seasonalFactor = Math.sin(declRad);
// Adjust angles for Fajr and Isha
const fajrAngle = baseAngle + refraction + elevationAdjustment;
const ishaAngle = baseAngle - refraction - elevationAdjustment;
// 4) Base and amplitude for twilight angle (±2° variation)
const baseAngle = 18.0;
const amplitude = 2.0;
const seasonalAngle = baseAngle + amplitude * seasonalFactor;
return { fajrAngle, ishaAngle };
// 5) Refraction correction at horizon (altitude = 0)
const refraction = calculateAtmosphericRefraction(0, pressure, temperature);
// 6) Elevation adjustment (0.1° per 1000m)
const elevationAdjustment = (elevation / 1000) * 0.1;
// 7) Final angles
const fajrAngle = seasonalAngle + refraction + elevationAdjustment;
const ishaAngle = seasonalAngle - refraction - elevationAdjustment;
return { fajrAngle, ishaAngle };
}
/**
* Compute atmospheric refraction correction at a given altitude.
*
* @param {number} altitude Solar altitude in degrees.
* @param {number} pressure Atmospheric pressure in mbar.
* @param {number} temperature Temperature in °C.
* @returns {number} Refraction correction in degrees.
*/
function calculateAtmosphericRefraction(altitude, pressure = 1013.25, temperature = 10) {
const altInRadians = altitude * Math.PI / 180;
let R = 1.0 / Math.tan(altInRadians + 7.31 / (altInRadians + 0.077));
R = R / 60; // Convert from arcminutes to degrees
R = (pressure / 1010) * (283 / (273 + temperature)) * R;
return R;
const altRad = altitude * PI / 180;
let R = 1.0 / Math.tan(altRad + 7.31 / (altRad + 0.077));
R = R / 60; // arcminutes → degrees
R = (pressure / 1010) * (283 / (273 + temperature)) * R;
return R;
}
module.exports = {
getAngles
};
module.exports = { getAngles };

View file

@ -1,28 +1,66 @@
const { getSpa, fractalTime } = require('nrel-spa');
// getAsr.js
'use strict';
function getAsr(solarNoonDate, latitude, longitude, tz, standard = true) {
const solarNoonAltitude = getSpa(solarNoonDate, latitude, longitude, tz).zenith;
const shadowLengthAtNoon = 1 / Math.tan((90 - solarNoonAltitude) * Math.PI / 180);
const { SpaData, spa_calculate, SPA_ZA_RTS } = require('nrel-spa/dist/spa');
// For standard method: Shadow length = object height + shadow length at noon
// For Hanafi method: Shadow length = 2 * object height + shadow length at noon
const targetShadowLength = standard ? 1 + shadowLengthAtNoon : 2 + shadowLengthAtNoon;
/**
* Compute Asr time (fractional hours) for a given date and location.
*
* @param {Date} date - Local date/time for the calculation.
* @param {number} latitude - Observer latitude in decimal degrees.
* @param {number} longitude - Observer longitude in decimal degrees.
* @param {number} timezone - Timezone offset from UTC in hours (negative west).
* @param {boolean}[standard] - true for Shāfiʿī (shadow=1), false for Ḥanafī (shadow=2).
* @returns {number|null} Fractionalhour Asr time (local), or null if unreachable.
*/
function getAsr(date, latitude, longitude, timezone, standard = true) {
// Load inputs into SPA struct
const data = new SpaData();
data.year = date.getFullYear();
data.month = date.getMonth() + 1;
data.day = date.getDate();
data.hour = date.getHours();
data.minute = date.getMinutes();
data.second = date.getSeconds();
data.delta_ut1 = 0.0;
data.delta_t = 67.0;
data.timezone = timezone;
data.longitude = longitude;
data.latitude = latitude;
data.elevation = 0.0;
data.pressure = 1013.0;
data.temperature = 15.0;
data.slope = 0.0;
data.azm_rotation = 0.0;
data.atmos_refract = 0.5667;
data.function = SPA_ZA_RTS;
// Increment time from noon to find Asr time
let asrTime;
for (let i = 0; i < 720; i++) { // Check next 12 hours
const testTime = new Date(solarNoonDate.getTime() + i * 60000); // Increment by one minute
const currentAltitude = getSpa(testTime, latitude, longitude, tz).zenith;
const currentShadowLength = 1 / Math.tan((90 - currentAltitude) * Math.PI / 180);
if (currentShadowLength >= targetShadowLength) {
asrTime = testTime;
break;
}
}
// Perform SPA calculation
if (spa_calculate(data) !== 0) return null;
return asrTime ? asrTime.getHours() + asrTime.getMinutes() / 60 + asrTime.getSeconds() / 3600 : null;
// Convert angles to radians
const φ = latitude * Math.PI / 180;
const δ = data.delta * Math.PI / 180;
const transit = data.suntransit; // fractionalhour solar noon
// Compute required solar elevation A for Asr:
const shadowFactor = standard ? 1 : 2;
const X = Math.abs(φ - δ);
const opp = 1;
const adj = shadowFactor + Math.tan(X);
const hyp = Math.hypot(opp, adj);
const sinA = opp / hyp;
// Solve hourangle H0: cos(H0) = (sinA - sinφ·sinδ) / (cosφ·cosδ)
const cosH0 = (sinA - Math.sin(φ) * Math.sin(δ)) /
(Math.cos(φ) * Math.cos(δ));
if (cosH0 < -1 || cosH0 > 1) return null; // sun never reaches A
// Convert H0 (rad) to hours
const H0h = (Math.acos(cosH0) * 180 / Math.PI) / 15;
// Asr time = solar noon + H0h
return transit + H0h;
}
module.exports = {
getAsr
};
module.exports = { getAsr };

170
getMSC.js
View file

@ -1,95 +1,125 @@
// getMSC.js
'use strict';
/**
* Base class for Moonsighting.com seasonal interpolation algorithm.
* Computes a day-of-year offset (dyy) from the nearest solstice and
* interpolates minutes for Fajr (before sunrise) or Isha (after sunset).
*/
class PrayerTimes {
constructor(date, latitude) {
this.date = new Date(date);
this.latitude = latitude;
this.getDyy();
}
constructor(date, latitude) {
this.date = new Date(date.getFullYear(), date.getMonth(), date.getDate());
this.latitude = latitude;
this.year = this.date.getFullYear();
this.daysInYear = isLeapYear(this.year) ? 366 : 365;
this.computeDyy();
}
getDyy() {
const year = this.date.getFullYear();
const northDate = new Date(`${year}-12-21`);
const southDate = new Date(`${year}-06-21`);
const zeroDate = this.latitude > 0 ? northDate : southDate;
this.dyy = Math.floor((this.date - zeroDate) / (1000 * 60 * 60 * 24));
if (this.dyy < 0) {
this.dyy += 365;
}
}
computeDyy() {
// Reference solstice: Dec 21 (Northern Hemisphere) or Jun 21 (Southern)
const northSolstice = new Date(this.year, 11, 21);
const southSolstice = new Date(this.year, 5, 21);
const zeroDate = this.latitude >= 0 ? northSolstice : southSolstice;
getMinutes() {
if (this.dyy < 91)
return this.a + ((this.b - this.a) / 91) * this.dyy;
if (this.dyy < 137)
return this.b + ((this.c - this.b) / 46) * (this.dyy - 91);
if (this.dyy < 183)
return this.c + ((this.d - this.c) / 46) * (this.dyy - 137);
if (this.dyy < 229)
return this.d + ((this.c - this.d) / 46) * (this.dyy - 183);
if (this.dyy < 275)
return this.c + ((this.b - this.c) / 46) * (this.dyy - 229);
return this.b + ((this.a - this.b) / 91) * (this.dyy - 275);
let diffDays = Math.floor((this.date - zeroDate) / 86400000);
if (diffDays < 0) diffDays += this.daysInYear;
this.dyy = diffDays;
}
getMinutesSegment() {
const { a, b, c, d, dyy, daysInYear } = this;
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);
}
}
}
/**
* Fajr: returns minutes before sunrise.
*/
class Fajr extends PrayerTimes {
constructor(date, latitude) {
super(date, latitude);
this.a = 75 + (28.65 / 55) * Math.abs(latitude);
this.b = 75 + (19.44 / 55) * Math.abs(latitude);
this.c = 75 + (32.74 / 55) * Math.abs(latitude);
this.d = 75 + (48.1 / 55) * Math.abs(latitude);
}
constructor(date, latitude) {
super(date, latitude);
const latAbs = Math.abs(latitude);
this.a = 75 + (28.65 / 55) * latAbs;
this.b = 75 + (19.44 / 55) * latAbs;
this.c = 75 + (32.74 / 55) * latAbs;
this.d = 75 + (48.10 / 55) * latAbs;
}
getMinutesBeforeSunrise() {
return Math.round(this.getMinutes());
}
getMinutesBeforeSunrise() {
return Math.round(this.getMinutesSegment());
}
}
/**
* Isha: returns minutes after sunset.
*/
class Isha extends PrayerTimes {
static SHAFAQ_AHMER = 'ahmer';
static SHAFAQ_ABYAD = 'abyad';
static SHAFAQ_GENERAL = 'general';
static SHAFAQ_GENERAL = 'general';
static SHAFAQ_AHMER = 'ahmer';
static SHAFAQ_ABYAD = 'abyad';
constructor(date, latitude, shafaq = Isha.SHAFAQ_GENERAL) {
super(date, latitude);
this.setShafaq(shafaq);
constructor(date, latitude, shafaq = Isha.SHAFAQ_GENERAL) {
super(date, latitude);
this.setShafaq(shafaq);
}
setShafaq(shafaq) {
this.shafaq = shafaq;
const latAbs = Math.abs(this.latitude);
switch (shafaq) {
case Isha.SHAFAQ_AHMER:
this.a = 62 + (17.4 / 55) * latAbs;
this.b = 62 - (7.16 / 55) * latAbs;
this.c = 62 + (5.12 / 55) * latAbs;
this.d = 62 + (19.44 / 55) * latAbs;
break;
case Isha.SHAFAQ_ABYAD:
this.a = 75 + (25.6 / 55) * latAbs;
this.b = 75 + (7.16 / 55) * latAbs;
this.c = 75 + (36.84 / 55) * latAbs;
this.d = 75 + (81.84 / 55) * latAbs;
break;
default: // general
this.a = 75 + (25.6 / 55) * latAbs;
this.b = 75 + (2.05 / 55) * latAbs;
this.c = 75 - (9.21 / 55) * latAbs;
this.d = 75 + (6.14 / 55) * latAbs;
break;
}
}
setShafaq(shafaq) {
this.shafaq = shafaq;
getMinutesAfterSunset() {
return Math.round(this.getMinutesSegment());
}
}
if (shafaq === Isha.SHAFAQ_AHMER) {
this.a = 62 + (17.4 / 55) * Math.abs(this.latitude);
this.b = 62 - (7.16 / 55) * Math.abs(this.latitude);
this.c = 62 + (5.12 / 55) * Math.abs(this.latitude);
this.d = 62 + (19.44 / 55) * Math.abs(this.latitude);
} else if (shafaq === Isha.SHAFAQ_ABYAD) {
this.a = 75 + (25.6 / 55) * Math.abs(this.latitude);
this.b = 75 + (7.16 / 55) * Math.abs(this.latitude);
this.c = 75 + (36.84 / 55) * Math.abs(this.latitude);
this.d = 75 + (81.84 / 55) * Math.abs(this.latitude);
} else {
this.a = 75 + (25.6 / 55) * Math.abs(this.latitude);
this.c = 75 - (9.21 / 55) * Math.abs(this.latitude);
this.b = 75 + (2.05 / 55) * Math.abs(this.latitude);
this.d = 75 + (6.14 / 55) * Math.abs(this.latitude);
}
}
getMinutesAfterSunset() {
return Math.round(this.getMinutes());
}
function isLeapYear(year) {
return (year % 4 === 0 && year % 100 !== 0) || (year % 400 === 0);
}
function getFajr(date, latitude) {
const fajr = new Fajr(date, latitude);
return fajr.getMinutesBeforeSunrise();
return new Fajr(date, latitude).getMinutesBeforeSunrise();
}
function getIsha(date, latitude, shafaq = Isha.SHAFAQ_GENERAL) {
const isha = new Isha(date, latitude, shafaq);
return isha.getMinutesAfterSunset();
return new Isha(date, latitude, shafaq).getMinutesAfterSunset();
}
module.exports = { getFajr, getIsha, Isha, Fajr };

15
package-lock.json generated
View file

@ -1,22 +1,23 @@
{
"name": "pray-calc",
"version": "1.5.0",
"version": "1.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pray-calc",
"version": "1.5.0",
"license": "ISC",
"version": "1.6.0",
"license": "MIT",
"dependencies": {
"nrel-spa": "^1.2.2",
"nrel-spa": "^1.3.0",
"suncalc": "^1.9.0"
}
},
"node_modules/nrel-spa": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/nrel-spa/-/nrel-spa-1.2.2.tgz",
"integrity": "sha512-2mycHd7PP0l+pPjPvrT6vr+PRHufCFOxsPcpaIOQ9GwhhfzgyTvSsLKPsqC6ZENNU65yq4PetT5XlWj3CoLjiQ=="
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/nrel-spa/-/nrel-spa-1.3.0.tgz",
"integrity": "sha512-vZPD4TjCxGQsrstyyZGtnJtU8y1bJghBki5MR9HbDyhGsO+mHM+l3ffI7l3rIjTJsJBoQP7gyDwQntiPy3vUHA==",
"license": "MIT"
},
"node_modules/suncalc": {
"version": "1.9.0",

View file

@ -1,19 +1,43 @@
{
"name": "pray-calc",
"version": "1.5.1",
"description": "Prayer times calculator using nrel-spa and custom formula for Fajr and Isha angles (as well as traditional static angle methods in the All function)",
"version": "1.6.0",
"description": "Prayer times calculator using nrel-spa and custom formula for Fajr and Isha angles (as well as traditional static angle methods)",
"main": "index.js",
"scripts": {
"test": "node test.js"
},
"author": "Ummat",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/ummeco/pray-calc.git"
},
"homepage": "https://github.com/ummeco/pray-calc#readme",
"keywords": [
"prayer",
"islam",
"fajr",
"dhuhr",
"asr",
"maghrib",
"isha",
"nrel-spa",
"solar-position"
],
"author": "Ummat",
"license": "MIT",
"bugs": {
"url": "https://github.com/ummeco/pray-calc/issues"
}
},
"homepage": "https://github.com/ummeco/pray-calc#readme",
"dependencies": {
"nrel-spa": "^1.3.0",
"suncalc": "^1.9.0"
},
"files": [
"index.js",
"getTimes.js",
"getAsr.js",
"getAngles.js",
"dist/",
"README.md",
"CHANGELOG.md"
]
}

View file

@ -1,13 +1,14 @@
const { getTimes, calcTimesAll } = require('./index');
// Manually setting the date to January 1, 2024
const date = new Date('2024-01-01T00:00:00Z');
//const date = new Date('2024-01-01T00:00:00Z');
const date = new Date();
// NYC - minimum params
const city = "New York"
const lat = 40.7128;
const lng = -74.006;
const tz = -5
const tz = -4
const elevation = null
const temperature = null
const pressure = null