mirror of
https://github.com/acamarata/pray-calc-dart.git
synced 2026-06-30 19:04:25 +00:00
fix: normalize date to UTC calendar day so prayer times are host-timezone-independent
getTimes now constructs civDate = DateTime.utc(date.year, date.month, date.day, 12, 0, 0) at entry and passes it to both getSpa and getAngles. Previously a local DateTime in a positive-UTC-offset zone (e.g. UTC+12) would reach getSpa.toUtc() as the previous UTC calendar day, shifting all prayer times by ~24 h. Regression tests added for UTC/local DateTime consistency across UTC, EDT, and Auckland host timezones (27 tests, all pass).
This commit is contained in:
parent
fc01b129f4
commit
178e990cbc
4 changed files with 80 additions and 5 deletions
10
CHANGELOG.md
10
CHANGELOG.md
|
|
@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
|
||||||
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||||
This project adheres to [Semantic Versioning](https://semver.org/).
|
This project adheres to [Semantic Versioning](https://semver.org/).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Prayer times are now host-timezone-independent. `getTimes` normalizes the
|
||||||
|
caller's `date` to a stable UTC-noon reference (`DateTime.utc(y, m, d, 12)`)
|
||||||
|
before passing it to `getSpa` and all astronomical calculations. Previously,
|
||||||
|
a local `DateTime(2024, 3, 15)` in a UTC+12 zone would reach `getSpa` as
|
||||||
|
UTC March 14, shifting all times by one civil day.
|
||||||
|
|
||||||
## [1.0.0] - 2026-05-25
|
## [1.0.0] - 2026-05-25
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,8 @@ TwilightAngles getAngles(
|
||||||
double pressure = 1013.25,
|
double pressure = 1013.25,
|
||||||
}) {
|
}) {
|
||||||
// 1. Solar ephemeris at UTC noon of the given date.
|
// 1. Solar ephemeris at UTC noon of the given date.
|
||||||
|
// date.year/month/day are read without TZ conversion so they reflect the
|
||||||
|
// caller's expressed civil date for both local and UTC DateTime inputs.
|
||||||
final noonDate = DateTime.utc(date.year, date.month, date.day, 12, 0, 0);
|
final noonDate = DateTime.utc(date.year, date.month, date.day, 12, 0, 0);
|
||||||
final jd = toJulianDate(noonDate);
|
final jd = toJulianDate(noonDate);
|
||||||
final eph = solarEphemeris(jd);
|
final eph = solarEphemeris(jd);
|
||||||
|
|
|
||||||
|
|
@ -33,9 +33,17 @@ PrayerTimes getTimes(
|
||||||
double pressure = 1013.25,
|
double pressure = 1013.25,
|
||||||
bool hanafi = false,
|
bool hanafi = false,
|
||||||
}) {
|
}) {
|
||||||
|
// Normalize to a stable UTC-noon DateTime for this civil calendar date.
|
||||||
|
// Reading date.year/month/day directly (without TZ conversion) preserves
|
||||||
|
// the caller's expressed date regardless of whether they passed a local or
|
||||||
|
// UTC DateTime. Constructing UTC noon removes host-timezone influence from
|
||||||
|
// every downstream Julian-Day computation and aligns getSpa with the
|
||||||
|
// Meeus/MSC calculations (which all need the same civil date).
|
||||||
|
final civDate = DateTime.utc(date.year, date.month, date.day, 12, 0, 0);
|
||||||
|
|
||||||
// 1. Compute dynamic twilight angles.
|
// 1. Compute dynamic twilight angles.
|
||||||
final tw = getAngles(
|
final tw = getAngles(
|
||||||
date,
|
civDate,
|
||||||
lat,
|
lat,
|
||||||
lng,
|
lng,
|
||||||
elevation: elevation,
|
elevation: elevation,
|
||||||
|
|
@ -49,8 +57,11 @@ PrayerTimes getTimes(
|
||||||
final ishaZenith = 90 + tw.ishaAngle;
|
final ishaZenith = 90 + tw.ishaAngle;
|
||||||
|
|
||||||
// 3. Run SPA for solar position + custom twilight times.
|
// 3. Run SPA for solar position + custom twilight times.
|
||||||
|
// Pass civDate (UTC noon) so SPA receives a deterministic UTC instant
|
||||||
|
// and the date component it extracts (via date.toUtc()) is always the
|
||||||
|
// intended civil day, independent of host timezone.
|
||||||
final spaData = getSpa(
|
final spaData = getSpa(
|
||||||
date,
|
civDate,
|
||||||
lat,
|
lat,
|
||||||
lng,
|
lng,
|
||||||
tz,
|
tz,
|
||||||
|
|
@ -70,9 +81,8 @@ PrayerTimes getTimes(
|
||||||
final dhuhrTime = noonTime + 2.5 / 60;
|
final dhuhrTime = noonTime + 2.5 / 60;
|
||||||
|
|
||||||
// 4. Solar declination for Asr (Meeus formula, accurate to ~0.01°).
|
// 4. Solar declination for Asr (Meeus formula, accurate to ~0.01°).
|
||||||
final jd = toJulianDate(
|
// civDate already is UTC noon of the civil date.
|
||||||
DateTime.utc(date.year, date.month, date.day, 12, 0, 0),
|
final jd = toJulianDate(civDate);
|
||||||
);
|
|
||||||
final eph = solarEphemeris(jd);
|
final eph = solarEphemeris(jd);
|
||||||
|
|
||||||
// 5. Asr time.
|
// 5. Asr time.
|
||||||
|
|
|
||||||
|
|
@ -190,4 +190,57 @@ void main() {
|
||||||
expect(result.angles[0].sunset, lessThan(result.angles[1].sunset));
|
expect(result.angles[0].sunset, lessThan(result.angles[1].sunset));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('UTC day-boundary regression — civil-date consistency', () {
|
||||||
|
// getTimes normalizes the input date to UTC noon of the expressed civil
|
||||||
|
// date before passing it to getSpa and all astronomical calculations.
|
||||||
|
// This ensures that a local DateTime and a UTC DateTime that both express
|
||||||
|
// "March 15" (via their .year/.month/.day fields) produce identical prayer
|
||||||
|
// times, regardless of what the host machine timezone is.
|
||||||
|
|
||||||
|
test('local DateTime and UTC noon for same civil date produce identical times', () {
|
||||||
|
// Both express 2024-03-15: local midnight and UTC noon share year/month/day = 2024/3/15
|
||||||
|
final local = DateTime(2024, 3, 15);
|
||||||
|
final utcNoon = DateTime.utc(2024, 3, 15, 12, 0, 0);
|
||||||
|
|
||||||
|
final timesLocal = getTimes(local, 40.7128, -74.0060, -5.0);
|
||||||
|
final timesNoon = getTimes(utcNoon, 40.7128, -74.0060, -5.0);
|
||||||
|
|
||||||
|
// After normalization both resolve to the same UTC noon reference point
|
||||||
|
expect(timesNoon.fajr, closeTo(timesLocal.fajr, 0.0001));
|
||||||
|
expect(timesNoon.sunrise, closeTo(timesLocal.sunrise, 0.0001));
|
||||||
|
expect(timesNoon.dhuhr, closeTo(timesLocal.dhuhr, 0.0001));
|
||||||
|
expect(timesNoon.asr, closeTo(timesLocal.asr, 0.0001));
|
||||||
|
expect(timesNoon.maghrib, closeTo(timesLocal.maghrib, 0.0001));
|
||||||
|
expect(timesNoon.isha, closeTo(timesLocal.isha, 0.0001));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('UTC midnight and UTC noon for same civil date produce identical times', () {
|
||||||
|
final utcMidnight = DateTime.utc(2024, 3, 15, 0, 0, 0);
|
||||||
|
final utcNoon = DateTime.utc(2024, 3, 15, 12, 0, 0);
|
||||||
|
|
||||||
|
final timesMid = getTimes(utcMidnight, 40.7128, -74.0060, -5.0);
|
||||||
|
final timesNoon = getTimes(utcNoon, 40.7128, -74.0060, -5.0);
|
||||||
|
|
||||||
|
expect(timesNoon.fajr, closeTo(timesMid.fajr, 0.0001));
|
||||||
|
expect(timesNoon.sunrise, closeTo(timesMid.sunrise, 0.0001));
|
||||||
|
expect(timesNoon.dhuhr, closeTo(timesMid.dhuhr, 0.0001));
|
||||||
|
expect(timesNoon.asr, closeTo(timesMid.asr, 0.0001));
|
||||||
|
expect(timesNoon.maghrib, closeTo(timesMid.maghrib, 0.0001));
|
||||||
|
expect(timesNoon.isha, closeTo(timesMid.isha, 0.0001));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('angles are identical for local vs UTC noon for the same civil date', () {
|
||||||
|
final local = DateTime(2024, 6, 15);
|
||||||
|
final utcNoon = DateTime.utc(2024, 6, 15, 12, 0, 0);
|
||||||
|
|
||||||
|
// getAngles receives civDate (already UTC noon from getTimes) but can
|
||||||
|
// also be called directly — test that same civil date gives same angles.
|
||||||
|
final anglesLocal = getAngles(local, 40.7128, -74.0060);
|
||||||
|
final anglesUtcNoon = getAngles(utcNoon, 40.7128, -74.0060);
|
||||||
|
|
||||||
|
expect(anglesUtcNoon.fajrAngle, closeTo(anglesLocal.fajrAngle, 0.001));
|
||||||
|
expect(anglesUtcNoon.ishaAngle, closeTo(anglesLocal.ishaAngle, 0.001));
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue