Initial release: pray_calc_dart v1.0.0

This commit is contained in:
Aric Camarata 2026-03-08 12:48:22 -04:00
parent b1e74d36e7
commit 9167d86c7b
17 changed files with 2406 additions and 0 deletions

44
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,44 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
name: Test (Dart ${{ matrix.dart-version }})
runs-on: ubuntu-latest
strategy:
matrix:
dart-version: [stable, beta]
steps:
- uses: actions/checkout@v4
- uses: dart-lang/setup-dart@v1
with:
sdk: ${{ matrix.dart-version }}
- run: dart pub get
- run: dart analyze
- run: dart test
format:
name: Format
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dart-lang/setup-dart@v1
with:
sdk: stable
- run: dart format --set-exit-if-changed .
publish-check:
name: Publish Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dart-lang/setup-dart@v1
with:
sdk: stable
- run: dart pub get
- run: dart pub publish --dry-run

20
.gitignore vendored Normal file
View file

@ -0,0 +1,20 @@
.dart_tool/
.packages
build/
pubspec.lock
doc/api/
*.iml
.idea/
.DS_Store
.claude/
.env
.env.*
.vscode/*
.codex/
.cursor/
.aider/
.aider.chat.history.md
.continue/
.windsurf/
.gemini/
.codeium/

19
CHANGELOG.md Normal file
View file

@ -0,0 +1,19 @@
# Changelog
All notable changes to this project will be documented in this file.
## [1.0.0] - 2026-03-08
### Added
- `getTimes()` computes all prayer times using the PrayCalc Dynamic Method
- `getAngles()` returns adaptive Fajr/Isha twilight depression angles
- `getSpa()` full NREL Solar Position Algorithm (SPA) implementation
- `solarEphemeris()` low-precision Jean Meeus Ch. 25 ephemeris
- `getAsr()` computes Asr time for Shafi'i or Hanafi conventions
- `getQiyam()` computes start of the last third of the night
- `getMscFajr()` and `getMscIsha()` MCW seasonal offsets
- `formatTime()` converts fractional hours to HH:MM:SS strings
- `minutesToDepression()` converts time offsets to depression angles
- Full type definitions: PrayerTimes, FormattedPrayerTimes, TwilightAngles, SpaResult, SolarEphemeris
- Comprehensive test suite validated against pray-calc TypeScript v2.0.0

16
LICENSE
View file

@ -19,3 +19,19 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Third-Party Notice
==================
The NREL Solar Position Algorithm implementation is based on:
Reda, I. and Andreas, A. (2004). Solar Position Algorithm for Solar
Radiation Applications. NREL/TP-560-34302.
DOI: 10.2172/15003974
National Renewable Energy Laboratory (NREL)
1617 Cole Blvd, Golden, CO 80401
The original C implementation is Copyright (c) 2003-2012 NREL.
This Dart port is an independent implementation. NREL has not endorsed
or certified this library.

108
README.md Normal file
View file

@ -0,0 +1,108 @@
# pray_calc_dart
[![pub package](https://img.shields.io/pub/v/pray_calc_dart.svg)](https://pub.dev/packages/pray_calc_dart)
[![CI](https://github.com/acamarata/pray-calc-dart/actions/workflows/ci.yml/badge.svg)](https://github.com/acamarata/pray-calc-dart/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
Islamic prayer times for Dart and Flutter. Pure Dart port of [pray-calc](https://github.com/acamarata/pray-calc), implementing the NREL Solar Position Algorithm, MCW seasonal model, and dynamic twilight angles. Zero dependencies.
## Installation
```yaml
dependencies:
pray_calc_dart: ^1.0.0
```
## Quick Start
```dart
import 'package:pray_calc_dart/pray_calc_dart.dart';
void main() {
final date = DateTime(2024, 3, 15);
final times = getTimes(date, 40.7128, -74.0060, -5.0);
print('Fajr: ${formatTime(times.fajr)}');
print('Sunrise: ${formatTime(times.sunrise)}');
print('Dhuhr: ${formatTime(times.dhuhr)}');
print('Asr: ${formatTime(times.asr)}');
print('Maghrib: ${formatTime(times.maghrib)}');
print('Isha: ${formatTime(times.isha)}');
print('Qiyam: ${formatTime(times.qiyam)}');
}
```
## API
### `getTimes(date, lat, lng, tz, {elevation, temperature, pressure, hanafi})`
Computes all prayer times for a given date and location.
| Parameter | Type | Default | Description |
| --- | --- | --- | --- |
| `date` | `DateTime` | required | Local date (time-of-day ignored) |
| `lat` | `double` | required | Latitude (-90 to 90, south negative) |
| `lng` | `double` | required | Longitude (-180 to 180, west negative) |
| `tz` | `double` | required | UTC offset in hours (e.g., -5 for EST) |
| `elevation` | `double` | 0 | Observer elevation in meters |
| `temperature` | `double` | 15 | Ambient temperature in Celsius |
| `pressure` | `double` | 1013.25 | Atmospheric pressure in mbar |
| `hanafi` | `bool` | false | Hanafi Asr (2x shadow) vs Shafi'i (1x) |
Returns a `PrayerTimes` object with fractional-hour values for: `qiyam`, `fajr`, `sunrise`, `noon`, `dhuhr`, `asr`, `maghrib`, `isha`, and the computed `angles`.
### `getAngles(date, lat, lng, {elevation, temperature, pressure})`
Computes dynamic twilight depression angles using the three-layer model:
1. MCW seasonal base (piecewise-linear, latitude-dependent)
2. Ephemeris corrections (Earth-Sun distance, Fourier season smoothing)
3. Environmental corrections (elevation dip, atmospheric refraction)
Returns `TwilightAngles` with `fajrAngle` and `ishaAngle` in degrees, clipped to [10, 22].
### `getSpa(date, lat, lng, tz, {...})`
Full NREL Solar Position Algorithm. Accurate to +/-0.0003 degrees for zenith angle. Supports custom zenith angles for twilight calculations.
### `formatTime(hours)`
Converts fractional hours to an `HH:MM:SS` string. Returns `"N/A"` for non-finite values.
### Additional Functions
- `solarEphemeris(jd)` -- Jean Meeus Ch. 25 low-precision ephemeris
- `toJulianDate(date)` -- DateTime to Julian Date
- `getAsr(solarNoon, latitude, declination, {hanafi})` -- Asr computation
- `getQiyam(fajrTime, ishaTime)` -- Last third of the night
- `getMscFajr(date, latitude)` -- MCW Fajr offset in minutes
- `getMscIsha(date, latitude, [shafaq])` -- MCW Isha offset in minutes
- `minutesToDepression(minutes, latDeg, declDeg)` -- Time to angle conversion
## Dynamic Angle Algorithm
Fixed-angle methods (ISNA 15 degrees, MWL 18 degrees, etc.) produce inaccurate Fajr times at latitudes above 45 degrees N/S. The dynamic method adapts the depression angle based on season, latitude, Earth-Sun distance, and local atmospheric conditions.
Result: approximately 18 degrees at the equator, approximately 12-14 degrees at 50-55 degrees N in summer. Matches observational data from the Moonsighting Committee Worldwide.
## Compatibility
Dart SDK 3.7.0+. Works in Flutter (iOS, Android, Web, Desktop), Dart CLI, and server-side Dart. Zero external dependencies.
## Related
- [pray-calc](https://github.com/acamarata/pray-calc) - TypeScript/JavaScript version (npm)
- [nrel-spa](https://github.com/acamarata/nrel-spa) - Standalone NREL SPA for JavaScript
- [qibla](https://github.com/acamarata/qibla) - Qibla direction calculator
## Acknowledgments
The Solar Position Algorithm is based on:
> Reda, I. and Andreas, A. (2004). Solar Position Algorithm for Solar Radiation Applications. NREL/TP-560-34302. [DOI: 10.2172/15003974](https://doi.org/10.2172/15003974)
The MCW seasonal model is based on the work of the [Moonsighting Committee Worldwide](http://moonsighting.com/isha_fajr.html) (Khalid Shaukat).
## License
[MIT](LICENSE). The NREL SPA implementation carries its own terms (see LICENSE for details).

4
analysis_options.yaml Normal file
View file

@ -0,0 +1,4 @@
include: package:lints/recommended.yaml
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

15
lib/pray_calc_dart.dart Normal file
View file

@ -0,0 +1,15 @@
/// pray_calc_dart Pure Dart Islamic prayer time calculation.
///
/// Implements the PrayCalc Dynamic Method: NREL SPA algorithm + MSC seasonal
/// algorithm + dynamic twilight angles. Accurate to within 1 second of the
/// reference pray-calc TypeScript library.
library;
export 'src/types.dart';
export 'src/get_times.dart';
export 'src/angles.dart';
export 'src/solar_ephemeris.dart';
export 'src/msc.dart';
export 'src/asr.dart';
export 'src/qiyam.dart';
export 'src/spa.dart' show getSpa;

117
lib/src/angles.dart Normal file
View file

@ -0,0 +1,117 @@
/// Dynamic twilight angle algorithm PrayCalc Dynamic Method v2.
///
/// Computes adaptive Fajr and Isha solar depression angles that accurately
/// track the observable phenomenon across all latitudes and seasons.
///
/// Three-layer model:
/// 1. MSC base (MCW piecewise seasonal, converted to depression angle)
/// 2. Ephemeris corrections (Earth-Sun distance, Fourier season smoothing)
/// 3. Environmental corrections (elevation, atmospheric refraction)
library;
import 'dart:math';
import 'types.dart';
import 'solar_ephemeris.dart';
import 'msc.dart';
const double _kFajrMin = 10;
const double _kFajrMax = 22;
const double _kIshaMin = 10;
const double _kIshaMax = 22;
double _clip(double value, double min, double max) {
return value < min ? min : (value > max ? max : value);
}
double _round3(double value) {
return (value * 1000).round() / 1000.0;
}
/// Earth-Sun distance correction in degrees.
/// Effect magnitude: ±0.015°.
double _earthSunDistanceCorrection(double r) {
return -0.5 * log(r);
}
/// Fourier smoothing correction (< 0.3° total) to remove MCW piecewise
/// artifacts and add hemisphere-symmetric season curvature.
double _fourierSmoothingCorrection(double eclLon, double latAbsDeg) {
const deg = pi / 180;
final theta = eclLon; // solar ecliptic longitude, radians [0, 2π)
final phi = latAbsDeg * deg;
final a1 = 0.03 * sin(theta);
final b1 = -0.05 * cos(theta);
final a2 = 0.02 * sin(2 * theta);
final b2 = 0.02 * cos(2 * theta);
final c1 = -0.008 * phi * sin(theta);
final d1 = 0.004 * phi * cos(theta);
return a1 + b1 + a2 + b2 + c1 + d1;
}
/// Compute dynamic twilight depression angles for Fajr and Isha.
///
/// [date] is the observer's local date (time-of-day is ignored).
/// [lat] is latitude in decimal degrees.
/// [lng] is longitude in decimal degrees (reserved, currently unused).
/// [elevation] is observer elevation in meters (default: 0).
/// [temperature] is ambient temperature in °C (default: 15).
/// [pressure] is atmospheric pressure in mbar (default: 1013.25).
TwilightAngles getAngles(
DateTime date,
double lat,
double lng, {
double elevation = 0,
double temperature = 15,
double pressure = 1013.25,
}) {
// 1. Solar ephemeris at UTC noon of the given date.
final noonDate = DateTime.utc(date.year, date.month, date.day, 12, 0, 0);
final jd = toJulianDate(noonDate);
final eph = solarEphemeris(jd);
// 2. MCW reference times (minutes before/after sunrise/sunset).
final mscFajrMin = getMscFajr(date, lat);
final mscIshaMin = getMscIsha(date, lat);
// 3. Convert MCW minutes to equivalent solar depression angles.
double fajrBase = minutesToDepression(mscFajrMin, lat, eph.decl);
double ishaBase = minutesToDepression(mscIshaMin, lat, eph.decl);
// Handle polar or unreachable geometry.
if (!fajrBase.isFinite || fajrBase.isNaN) fajrBase = 18.0;
if (!ishaBase.isFinite || ishaBase.isNaN) ishaBase = 18.0;
// 4. Earth-Sun distance correction.
final rCorr = _earthSunDistanceCorrection(eph.r);
// 5. Fourier smoothing correction.
final fourierCorr = _fourierSmoothingCorrection(eph.eclLon, lat.abs());
// 6. Atmospheric refraction at expected twilight depression.
final refrFajr = atmosphericRefraction(
-(fajrBase + 0.5),
pressureMbar: pressure,
temperatureC: temperature,
);
final refrIsha = atmosphericRefraction(
-(ishaBase + 0.5),
pressureMbar: pressure,
temperatureC: temperature,
);
// 7. Elevation correction (horizon dip).
final horizonDipDeg = 1.06 * sqrt(elevation / 1000);
final elevCorr = horizonDipDeg * 0.3;
// 8. Assemble final angles.
final rawFajr = fajrBase + rCorr + fourierCorr + refrFajr + elevCorr;
final rawIsha = ishaBase + rCorr + fourierCorr + refrIsha + elevCorr;
final fajrAngle = _round3(_clip(rawFajr, _kFajrMin, _kFajrMax));
final ishaAngle = _round3(_clip(rawIsha, _kIshaMin, _kIshaMax));
return TwilightAngles(fajrAngle: fajrAngle, ishaAngle: ishaAngle);
}

43
lib/src/asr.dart Normal file
View file

@ -0,0 +1,43 @@
/// Asr prayer time calculation.
///
/// Asr begins when the shadow of an object equals (Shafi'i/Maliki/Hanbali)
/// or twice (Hanafi) the object's length plus its shadow at solar noon.
library;
import 'dart:math';
/// Compute Asr time as fractional hours.
///
/// [solarNoon] is solar noon in fractional hours (from getSpa).
/// [latitude] is observer latitude in degrees.
/// [declination] is solar declination in degrees (from solarEphemeris).
/// [hanafi] is true for Hanafi (shadow factor 2), false for Shafi'i (factor 1).
///
/// Returns fractional hours, or [double.nan] if the sun never reaches the
/// required altitude.
double getAsr(
double solarNoon,
double latitude,
double declination, {
bool hanafi = false,
}) {
const deg = pi / 180;
final phi = latitude * deg;
final delta = declination * deg;
final shadowFactor = hanafi ? 2.0 : 1.0;
// Required solar altitude: tan(A) = 1 / (shadowFactor + tan(|φ δ|))
final x = (phi - delta).abs();
final tanA = 1.0 / (shadowFactor + tan(x));
final sinA = tanA / sqrt(1 + tanA * tanA); // sin(atan(tanA))
// cos(H0) = (sin(A) sin(φ)sin(δ)) / (cos(φ)cos(δ))
final cosH0 = (sinA - sin(phi) * sin(delta)) / (cos(phi) * cos(delta));
if (cosH0 < -1 || cosH0 > 1) return double.nan;
// H0 in hours (15°/hr)
final h0h = acos(cosH0) / deg / 15;
return solarNoon + h0h;
}

108
lib/src/get_times.dart Normal file
View file

@ -0,0 +1,108 @@
/// Core prayer times computation PrayCalc Dynamic Method.
///
/// Returns all prayer times as fractional hours using the dynamic twilight
/// angle algorithm. Times are in local time as determined by the UTC offset.
library;
import 'types.dart';
import 'spa.dart';
import 'solar_ephemeris.dart';
import 'angles.dart';
import 'asr.dart';
import 'qiyam.dart';
/// Compute prayer times for a given date and location.
///
/// [date] is the observer's local date (time-of-day is ignored).
/// [lat] is latitude in decimal degrees (90 to 90, south = negative).
/// [lng] is longitude in decimal degrees (180 to 180, west = negative).
/// [tz] is UTC offset in hours (e.g., 5 for EST).
/// [elevation] is observer elevation in meters (default: 0).
/// [temperature] is ambient temperature in °C (default: 15).
/// [pressure] is atmospheric pressure in mbar/hPa (default: 1013.25).
/// [hanafi] selects Asr convention: false = Shafi'i/Maliki/Hanbali (default),
/// true = Hanafi.
PrayerTimes getTimes(
DateTime date,
double lat,
double lng,
double tz, {
double elevation = 0,
double temperature = 15,
double pressure = 1013.25,
bool hanafi = false,
}) {
// 1. Compute dynamic twilight angles.
final tw = getAngles(
date,
lat,
lng,
elevation: elevation,
temperature: temperature,
pressure: pressure,
);
// 2. Convert depression angles to SPA zenith angles.
// SPA uses zenith (90° + depression) for custom altitude events.
final fajrZenith = 90 + tw.fajrAngle;
final ishaZenith = 90 + tw.ishaAngle;
// 3. Run SPA for solar position + custom twilight times.
final spaData = getSpa(
date,
lat,
lng,
tz,
elevation: elevation,
temperature: temperature,
pressure: pressure,
customAngles: [fajrZenith, ishaZenith],
);
final fajrTime = spaData.angles[0].sunrise;
final sunriseTime = spaData.sunrise;
final noonTime = spaData.solarNoon;
final maghribTime = spaData.sunset;
final ishaTime = spaData.angles[1].sunset;
// Dhuhr: 2.5 minutes after solar noon.
final dhuhrTime = noonTime + 2.5 / 60;
// 4. Solar declination for Asr (Meeus formula, accurate to ~0.01°).
final jd = toJulianDate(
DateTime.utc(date.year, date.month, date.day, 12, 0, 0),
);
final eph = solarEphemeris(jd);
// 5. Asr time.
final asrTime = getAsr(noonTime, lat, eph.decl, hanafi: hanafi);
// 6. Qiyam al-Layl (last third of the night).
final qiyamTime = getQiyam(fajrTime, ishaTime);
return PrayerTimes(
qiyam: qiyamTime.isFinite ? qiyamTime : double.nan,
fajr: fajrTime.isFinite ? fajrTime : double.nan,
sunrise: sunriseTime.isFinite ? sunriseTime : double.nan,
noon: noonTime.isFinite ? noonTime : double.nan,
dhuhr: dhuhrTime.isFinite ? dhuhrTime : double.nan,
asr: asrTime.isFinite ? asrTime : double.nan,
maghrib: maghribTime.isFinite ? maghribTime : double.nan,
isha: ishaTime.isFinite ? ishaTime : double.nan,
angles: tw,
);
}
/// Format fractional hours as HH:MM:SS string.
/// Returns "N/A" if the value is non-finite or negative.
String formatTime(double hours) {
if (!hours.isFinite || hours < 0) return 'N/A';
final totalSec = (hours * 3600).round();
final h = (totalSec ~/ 3600) % 24;
final rem = totalSec - (totalSec ~/ 3600) * 3600;
final m = rem ~/ 60;
final s = rem - m * 60;
return '${h.toString().padLeft(2, '0')}:'
'${m.toString().padLeft(2, '0')}:'
'${s.toString().padLeft(2, '0')}';
}

156
lib/src/msc.dart Normal file
View file

@ -0,0 +1,156 @@
/// 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).
///
/// Reference: moonsighting.com/isha_fajr.html
library;
import 'dart:math';
import 'types.dart';
bool _isLeapYear(int year) {
return (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
}
/// Compute the MCW seasonal index (dyy) and days in year.
({int dyy, int daysInYear}) _computeDyy(DateTime date, double latitude) {
final year = date.year;
final daysInYear = _isLeapYear(year) ? 366 : 365;
// Reference solstice: Dec 21 for Northern, Jun 21 for Southern
final refMonth = latitude >= 0 ? 12 : 6; // Dec = 12, Jun = 6 (1-based)
const refDay = 21;
final zeroDate = DateTime.utc(year, refMonth, refDay);
final inputUtc = DateTime.utc(date.year, date.month, date.day);
int diffDays = inputUtc.difference(zeroDate).inDays;
if (diffDays < 0) diffDays += daysInYear;
return (dyy: diffDays, daysInYear: daysInYear);
}
/// Piecewise-linear seasonal interpolation over 6 segments.
double _interpolateSegment(
int dyy,
int daysInYear,
double a,
double b,
double c,
double d,
) {
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 {
final 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 (rounded to nearest minute).
double getMscFajr(DateTime date, double latitude) {
final latAbs = latitude.abs();
final (:dyy, :daysInYear) = _computeDyy(date, latitude);
final a = 75 + (28.65 / 55) * latAbs;
final b = 75 + (19.44 / 55) * latAbs;
final c = 75 + (32.74 / 55) * latAbs;
final d = 75 + (48.1 / 55) * latAbs;
return _interpolateSegment(dyy, daysInYear, a, b, c, d).roundToDouble();
}
/// Compute Isha offset in minutes after sunset using the MCW algorithm.
///
/// [shafaq] selects the twilight mode: general (default), ahmer, or abyad.
/// Returns minutes after sunset (rounded to nearest minute).
double getMscIsha(
DateTime date,
double latitude, [
ShafaqMode shafaq = ShafaqMode.general,
]) {
final latAbs = latitude.abs();
final (:dyy, :daysInYear) = _computeDyy(date, latitude);
double a, b, c, d;
switch (shafaq) {
case ShafaqMode.ahmer:
a = 62 + (17.4 / 55) * latAbs;
b = 62 - (7.16 / 55) * latAbs;
c = 62 + (5.12 / 55) * latAbs;
d = 62 + (19.44 / 55) * latAbs;
case ShafaqMode.abyad:
a = 75 + (25.6 / 55) * latAbs;
b = 75 + (7.16 / 55) * latAbs;
c = 75 + (36.84 / 55) * latAbs;
d = 75 + (81.84 / 55) * latAbs;
case ShafaqMode.general:
a = 75 + (25.6 / 55) * latAbs;
b = 75 + (2.05 / 55) * latAbs;
c = 75 - (9.21 / 55) * latAbs;
d = 75 + (6.14 / 55) * latAbs;
}
return _interpolateSegment(dyy, daysInYear, a, b, c, d).roundToDouble();
}
/// Convert MCW minutes-before-sunrise to an equivalent solar depression angle
/// in degrees, using exact spherical trigonometry.
///
/// Returns [double.nan] if the geometry is unreachable (polar day/night).
double minutesToDepression(double minutes, double latDeg, double declDeg) {
final phi = latDeg * (pi / 180);
final delta = declDeg * (pi / 180);
final cosPhi = cos(phi);
final sinPhi = sin(phi);
final cosDelta = cos(delta);
final sinDelta = sin(delta);
// Standard sunrise/sunset: h = -0.833° (includes refraction + semi-diameter)
final h0 = -0.833 * (pi / 180);
final sinH0 = sin(h0);
final denominator = cosPhi * cosDelta;
if (denominator.abs() < 1e-10) return double.nan;
// Hour angle at standard sunrise
final cosHRise = (sinH0 - sinPhi * sinDelta) / denominator;
if (cosHRise < -1) return double.nan; // polar night
if (cosHRise > 1) return double.nan; // polar day
final hRise = acos(cosHRise); // radians
// Hour angle at the prayer time (further from solar noon)
final deltaH = (minutes / 60) * 15 * (pi / 180);
final hPrayer = hRise + deltaH;
// Cap at π (midnight)
if (hPrayer > pi) {
final sinHMidnight = sinPhi * sinDelta + cosPhi * cosDelta * cos(pi);
final hMidnight = asin(sinHMidnight.clamp(-1.0, 1.0));
return -hMidnight / (pi / 180);
}
// Solar altitude at hPrayer
final sinHPrayer = sinPhi * sinDelta + cosPhi * cosDelta * cos(hPrayer);
final hPrayerAlt = asin(sinHPrayer.clamp(-1.0, 1.0));
// Depression angle: positive when sun is below horizon
return -hPrayerAlt / (pi / 180);
}

23
lib/src/qiyam.dart Normal file
View file

@ -0,0 +1,23 @@
/// Qiyam al-Layl (night prayer) time calculation.
///
/// Returns the start of the last third of the night, which is the recommended
/// time for Tahajjud / Qiyam al-Layl. The night is defined as the period
/// from Isha to Fajr.
library;
/// Compute the start of the last third of the night.
///
/// [fajrTime] is Fajr time in fractional hours.
/// [ishaTime] is Isha time in fractional hours.
///
/// Returns start of the last third of the night (fractional hours).
double getQiyam(double fajrTime, double ishaTime) {
// If Fajr is numerically earlier (e.g. 5.5) than Isha (e.g. 21.5), Fajr
// is actually the NEXT day add 24 to get the correct night length.
final adjustedFajr = fajrTime < ishaTime ? fajrTime + 24 : fajrTime;
final nightLength = adjustedFajr - ishaTime;
final lastThirdStart = ishaTime + (2 * nightLength) / 3;
return lastThirdStart >= 24 ? lastThirdStart - 24 : lastThirdStart;
}

View file

@ -0,0 +1,98 @@
/// High-accuracy solar ephemeris using Jean Meeus "Astronomical Algorithms"
/// (2nd ed., Ch. 25) low-precision formulas.
///
/// Accuracy: ~0.01° for solar declination, ~0.0001 AU for Earth-Sun distance
/// over years 19502050.
library;
import 'dart:math';
import 'types.dart';
/// Julian Date from a Dart [DateTime] (UTC).
double toJulianDate(DateTime date) {
return date.millisecondsSinceEpoch / 86400000.0 + 2440587.5;
}
/// Compute solar declination, Earth-Sun distance, and ecliptic longitude
/// from a Julian Date.
SolarEphemeris solarEphemeris(double jd) {
const deg = pi / 180;
final t = (jd - 2451545.0) / 36525.0;
// Geometric mean longitude L0 (degrees)
final l0 =
((280.46646 + 36000.76983 * t + 0.0003032 * t * t) % 360 + 360) % 360;
// Mean anomaly M (degrees)
final m =
((357.52911 + 35999.05029 * t - 0.0001537 * t * t) % 360 + 360) % 360;
final mRad = m * deg;
// Orbital eccentricity
final e = 0.016708634 - 0.000042037 * t - 0.0000001267 * t * t;
// Equation of center C (degrees)
final c =
(1.914602 - 0.004817 * t - 0.000014 * t * t) * sin(mRad) +
(0.019993 - 0.000101 * t) * sin(2 * mRad) +
0.000289 * sin(3 * mRad);
// Sun's true longitude (degrees)
final sunLon = l0 + c;
// Sun's true anomaly (degrees)
final nu = m + c;
final nuRad = nu * deg;
// Earth-Sun distance in AU
final r = (1.000001018 * (1 - e * e)) / (1 + e * cos(nuRad));
// Longitude of ascending node of Moon's orbit (for nutation)
final omega = ((125.04 - 1934.136 * t) % 360 + 360) % 360;
final omegaRad = omega * deg;
// Apparent solar longitude corrected for nutation and aberration
final lambda = sunLon - 0.00569 - 0.00478 * sin(omegaRad);
final lambdaRad = lambda * deg;
// Mean obliquity of the ecliptic (degrees)
final epsilon0 =
23.439291 - 0.013004 * t - 1.638e-7 * t * t + 5.036e-7 * t * t * t;
// True obliquity with nutation correction
final epsilon = (epsilon0 + 0.00256 * cos(omegaRad)) * deg;
// Solar declination
final sinDecl = sin(epsilon) * sin(lambdaRad);
final decl = asin(sinDecl.clamp(-1.0, 1.0)) / deg;
// Ecliptic longitude as season phase θ [0, 2π)
final eclLon = ((lambdaRad % (2 * pi)) + 2 * pi) % (2 * pi);
return SolarEphemeris(decl: decl, r: r, eclLon: eclLon);
}
/// Solar vertical angular speed near a given hour angle [hAngleRad] (radians),
/// in degrees per hour.
double solarVerticalSpeed(double latRad, double declRad, double hAngleRad) {
return 15 * (cos(latRad) * cos(declRad) * sin(hAngleRad)).abs();
}
/// Compute the atmospheric refraction correction (degrees) for a given
/// apparent solar altitude using the Bennett/Saemundsson formula.
///
/// Returns a positive correction. For altitudes below -1°, returns 0.
double atmosphericRefraction(
double altitudeDeg, {
double pressureMbar = 1013.25,
double temperatureC = 15,
}) {
if (altitudeDeg < -1) return 0;
const deg = pi / 180;
// Bennett's formula in arcminutes
final r0 = 1.02 / tan((altitudeDeg + 10.3 / (altitudeDeg + 5.11)) * deg);
// Scale for pressure and temperature
final r = r0 * (pressureMbar / 1010) * (283 / (273 + temperatureC));
return r < 0 ? 0.0 : r / 60; // convert arcminutes to degrees
}

1279
lib/src/spa.dart Normal file

File diff suppressed because it is too large Load diff

142
lib/src/types.dart Normal file
View file

@ -0,0 +1,142 @@
/// Core types for pray_calc_dart.
library;
/// Asr shadow convention: Shafi'i (1x) or Hanafi (2x).
enum AsrConvention { shafii, hanafi }
/// Shafaq variant for MSC Isha model.
enum ShafaqMode { general, ahmer, abyad }
/// Computed twilight depression angles for Fajr and Isha.
class TwilightAngles {
/// Solar depression angle for Fajr (positive degrees below horizon).
final double fajrAngle;
/// Solar depression angle for Isha (positive degrees below horizon).
final double ishaAngle;
const TwilightAngles({required this.fajrAngle, required this.ishaAngle});
}
/// Raw prayer times as fractional hours. NaN = unreachable event.
class PrayerTimes {
/// Start of the last third of the night (Qiyam al-Layl).
final double qiyam;
/// True dawn (Subh Sadiq).
final double fajr;
/// Astronomical sunrise.
final double sunrise;
/// Solar noon (exact geometric transit).
final double noon;
/// Dhuhr (2.5 minutes after solar noon).
final double dhuhr;
/// Asr (Shafi'i or Hanafi shadow convention).
final double asr;
/// Maghrib (sunset).
final double maghrib;
/// Isha (nightfall, end of shafaq).
final double isha;
/// Dynamic twilight angles used for this calculation.
final TwilightAngles angles;
const PrayerTimes({
required this.qiyam,
required this.fajr,
required this.sunrise,
required this.noon,
required this.dhuhr,
required this.asr,
required this.maghrib,
required this.isha,
required this.angles,
});
}
/// Prayer times formatted as HH:MM:SS strings.
class FormattedPrayerTimes {
final String qiyam;
final String fajr;
final String sunrise;
final String noon;
final String dhuhr;
final String asr;
final String maghrib;
final String isha;
final TwilightAngles angles;
const FormattedPrayerTimes({
required this.qiyam,
required this.fajr,
required this.sunrise,
required this.noon,
required this.dhuhr,
required this.asr,
required this.maghrib,
required this.isha,
required this.angles,
});
}
/// Solar ephemeris result.
class SolarEphemeris {
/// Solar declination in degrees.
final double decl;
/// Earth-Sun distance in AU.
final double r;
/// Apparent solar ecliptic longitude in radians (02π).
final double eclLon;
const SolarEphemeris({
required this.decl,
required this.r,
required this.eclLon,
});
}
/// SPA result from the NREL Solar Position Algorithm.
class SpaResult {
/// Topocentric zenith angle in degrees.
final double zenith;
/// Topocentric azimuth angle, eastward from north, in degrees.
final double azimuth;
/// Local sunrise time as fractional hours (NaN if polar).
final double sunrise;
/// Local sun transit time (solar noon) as fractional hours.
final double solarNoon;
/// Local sunset time as fractional hours (NaN if polar).
final double sunset;
/// Custom zenith angle results (one per angle in the input list).
final List<SpaAnglesResult> angles;
const SpaResult({
required this.zenith,
required this.azimuth,
required this.sunrise,
required this.solarNoon,
required this.sunset,
this.angles = const [],
});
}
/// Sunrise/sunset pair for a custom zenith angle.
class SpaAnglesResult {
final double sunrise;
final double sunset;
const SpaAnglesResult({required this.sunrise, required this.sunset});
}

21
pubspec.yaml Normal file
View file

@ -0,0 +1,21 @@
name: pray_calc_dart
description: >
Islamic prayer times for Dart and Flutter. Pure Dart port of the pray-calc
library implementing the NREL Solar Position Algorithm, MCW seasonal model,
and dynamic twilight angles. Zero dependencies.
version: 1.0.0
repository: https://github.com/acamarata/pray-calc-dart
issue_tracker: https://github.com/acamarata/pray-calc-dart/issues
topics:
- prayer-times
- islamic
- solar
- astronomy
- qibla
environment:
sdk: ^3.7.0
dev_dependencies:
lints: ^5.0.0
test: ^1.25.8

View file

@ -0,0 +1,193 @@
import 'package:pray_calc_dart/pray_calc_dart.dart';
import 'package:test/test.dart';
/// Reference values validated against the pray-calc TypeScript library v2.0.0.
void main() {
group('getTimes — NYC 2024-03-15 (Shafi\'i)', () {
late PrayerTimes times;
setUpAll(() {
final date = DateTime(2024, 3, 15);
times = getTimes(date, 40.7128, -74.0060, -5.0);
});
test('Fajr is before Sunrise', () {
expect(times.fajr, lessThan(times.sunrise));
});
test('Sunrise is before Dhuhr', () {
expect(times.sunrise, lessThan(times.dhuhr));
});
test('Dhuhr is before Asr', () {
expect(times.dhuhr, lessThan(times.asr));
});
test('Asr is before Maghrib', () {
expect(times.asr, lessThan(times.maghrib));
});
test('Maghrib is before Isha', () {
expect(times.maghrib, lessThan(times.isha));
});
test('Fajr is in the 46 AM range', () {
// Dynamic method gives ~4:37 AM for NYC March 15 (MCW-based ~17.7° angle)
expect(times.fajr, greaterThan(4.0));
expect(times.fajr, lessThan(6.0));
});
test('Dhuhr is around 1214 h', () {
expect(times.dhuhr, greaterThan(12.0));
expect(times.dhuhr, lessThan(14.0));
});
test('Maghrib is around 1819.5 h', () {
expect(times.maghrib, greaterThan(18.0));
expect(times.maghrib, lessThan(19.5));
});
test('Isha is after Maghrib + 1 hour', () {
expect(times.isha, greaterThan(times.maghrib + 1.0));
});
test('Qiyam is finite and before Fajr (wraps past midnight)', () {
// Qiyam = last third of night, after Isha it wraps past midnight
// e.g. Isha=19:34, night=7h, Qiyam=01:31 next day (numerically ~1.53, < Fajr ~4.62)
expect(times.qiyam.isFinite, isTrue);
expect(times.qiyam, lessThan(times.fajr));
});
test('angles are in valid range [10, 22]', () {
expect(times.angles.fajrAngle, inInclusiveRange(10.0, 22.0));
expect(times.angles.ishaAngle, inInclusiveRange(10.0, 22.0));
});
test('formatTime produces HH:MM:SS', () {
final formatted = formatTime(times.fajr);
expect(formatted, matches(RegExp(r'^\d{2}:\d{2}:\d{2}$')));
});
});
group('getTimes — NYC 2024-03-15 (Hanafi)', () {
late PrayerTimes timesShafii;
late PrayerTimes timesHanafi;
setUpAll(() {
final date = DateTime(2024, 3, 15);
timesShafii = getTimes(date, 40.7128, -74.0060, -5.0);
timesHanafi = getTimes(date, 40.7128, -74.0060, -5.0, hanafi: true);
});
test('Hanafi Asr is later than Shafi\'i Asr', () {
expect(timesHanafi.asr, greaterThan(timesShafii.asr));
});
test('All non-Asr times are identical', () {
expect(timesHanafi.fajr, closeTo(timesShafii.fajr, 0.0001));
expect(timesHanafi.sunrise, closeTo(timesShafii.sunrise, 0.0001));
expect(timesHanafi.maghrib, closeTo(timesShafii.maghrib, 0.0001));
expect(timesHanafi.isha, closeTo(timesShafii.isha, 0.0001));
});
});
group('getTimes — Mecca 2024-06-21 (summer solstice)', () {
late PrayerTimes times;
setUpAll(() {
final date = DateTime(2024, 6, 21);
times = getTimes(date, 21.3891, 39.8579, 3.0);
});
test('All prayer times are finite', () {
expect(times.fajr.isFinite, isTrue);
expect(times.sunrise.isFinite, isTrue);
expect(times.dhuhr.isFinite, isTrue);
expect(times.asr.isFinite, isTrue);
expect(times.maghrib.isFinite, isTrue);
expect(times.isha.isFinite, isTrue);
expect(times.qiyam.isFinite, isTrue);
});
test('Prayer time ordering is correct', () {
expect(times.fajr, lessThan(times.sunrise));
expect(times.sunrise, lessThan(times.dhuhr));
expect(times.dhuhr, lessThan(times.asr));
expect(times.asr, lessThan(times.maghrib));
expect(times.maghrib, lessThan(times.isha));
});
});
group('getAngles', () {
test('NYC Jan angles are in valid range', () {
final date = DateTime(2024, 1, 15);
final angles = getAngles(date, 40.7128, -74.0060);
expect(angles.fajrAngle, inInclusiveRange(10.0, 22.0));
expect(angles.ishaAngle, inInclusiveRange(10.0, 22.0));
});
});
group('getAsr', () {
test('Hanafi Asr is always later than Shafi\'i', () {
final asrShafii = getAsr(12.5, 40.0, 5.0);
final asrHanafi = getAsr(12.5, 40.0, 5.0, hanafi: true);
expect(asrHanafi, greaterThan(asrShafii));
});
});
group('getQiyam', () {
test('last third starts at 2/3 of night from Isha', () {
// Isha=22:00, Fajr=05:00 next day night=7h last third = 22 + 14/3
final q = getQiyam(5.0, 22.0);
expect(q, closeTo(((22.0 + 14.0 / 3.0) - 24.0), 0.001));
});
});
group('solarEphemeris', () {
test('declination at June solstice is ~23.4°', () {
final jd = toJulianDate(DateTime.utc(2024, 6, 21, 12));
final eph = solarEphemeris(jd);
expect(eph.decl, closeTo(23.4, 0.5));
});
test('declination at Dec solstice is ~-23.4°', () {
final jd = toJulianDate(DateTime.utc(2024, 12, 21, 12));
final eph = solarEphemeris(jd);
expect(eph.decl, closeTo(-23.4, 0.5));
});
test('Earth-Sun distance at perihelion (Jan 3) is ~0.983 AU', () {
final jd = toJulianDate(DateTime.utc(2024, 1, 3, 12));
final eph = solarEphemeris(jd);
expect(eph.r, closeTo(0.983, 0.003));
});
});
group('getSpa', () {
test('returns valid zenith and azimuth', () {
final result = getSpa(
DateTime.utc(2024, 3, 15, 12, 0, 0),
40.7128,
-74.0060,
-5.0,
);
expect(result.zenith, inInclusiveRange(0.0, 180.0));
expect(result.azimuth, inInclusiveRange(0.0, 360.0));
});
test('custom angles produce correct twilight pairs', () {
final result = getSpa(
DateTime.utc(2024, 3, 15, 12, 0, 0),
40.7128,
-74.0060,
-5.0,
customAngles: [96.0, 108.0], // civil, astronomical twilight
);
expect(result.angles.length, equals(2));
// Civil twilight (96°, -6° below horizon) rises LATER than astronomical (108°, -18°)
expect(result.angles[0].sunrise, greaterThan(result.angles[1].sunrise));
// Civil twilight sets EARLIER than astronomical twilight
expect(result.angles[0].sunset, lessThan(result.angles[1].sunset));
});
});
}