mirror of
https://github.com/acamarata/pray-calc-dart.git
synced 2026-06-30 19:04:25 +00:00
Initial release: pray_calc_dart v1.0.0
This commit is contained in:
parent
b1e74d36e7
commit
9167d86c7b
17 changed files with 2406 additions and 0 deletions
44
.github/workflows/ci.yml
vendored
Normal file
44
.github/workflows/ci.yml
vendored
Normal 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
20
.gitignore
vendored
Normal 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
19
CHANGELOG.md
Normal 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
16
LICENSE
|
|
@ -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
108
README.md
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
# pray_calc_dart
|
||||
|
||||
[](https://pub.dev/packages/pray_calc_dart)
|
||||
[](https://github.com/acamarata/pray-calc-dart/actions/workflows/ci.yml)
|
||||
[](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
4
analysis_options.yaml
Normal 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
15
lib/pray_calc_dart.dart
Normal 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
117
lib/src/angles.dart
Normal 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
43
lib/src/asr.dart
Normal 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
108
lib/src/get_times.dart
Normal 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
156
lib/src/msc.dart
Normal 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
23
lib/src/qiyam.dart
Normal 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;
|
||||
}
|
||||
98
lib/src/solar_ephemeris.dart
Normal file
98
lib/src/solar_ephemeris.dart
Normal 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 1950–2050.
|
||||
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
1279
lib/src/spa.dart
Normal file
File diff suppressed because it is too large
Load diff
142
lib/src/types.dart
Normal file
142
lib/src/types.dart
Normal 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 (0–2π).
|
||||
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
21
pubspec.yaml
Normal 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
|
||||
193
test/pray_calc_dart_test.dart
Normal file
193
test/pray_calc_dart_test.dart
Normal 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 4–6 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 12–14 h', () {
|
||||
expect(times.dhuhr, greaterThan(12.0));
|
||||
expect(times.dhuhr, lessThan(14.0));
|
||||
});
|
||||
|
||||
test('Maghrib is around 18–19.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));
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue