diff --git a/lib/src/adhan_base.dart b/lib/src/adhan_base.dart index 9c09d0f..7e35e82 100644 --- a/lib/src/adhan_base.dart +++ b/lib/src/adhan_base.dart @@ -5,6 +5,7 @@ export 'madhab.dart' show Madhab; export 'calculation_method.dart'; export 'calculation_parameters.dart'; export 'high_latitude_rule.dart'; +export 'polar_circle_resolution.dart'; export 'prayer_adjustments.dart'; export 'qibla.dart'; export 'sunnah_times.dart'; diff --git a/lib/src/calculation_parameters.dart b/lib/src/calculation_parameters.dart index 0842600..1ac4604 100644 --- a/lib/src/calculation_parameters.dart +++ b/lib/src/calculation_parameters.dart @@ -1,6 +1,7 @@ import 'calculation_method.dart'; import 'high_latitude_rule.dart'; import 'madhab.dart'; +import 'polar_circle_resolution.dart'; import 'prayer_adjustments.dart'; /// Parameters used for PrayerTime calculation customization @@ -30,6 +31,9 @@ class CalculationParameters { /// Rules for placing bounds on Fajr and Isha for high latitude areas HighLatitudeRule highLatitudeRule; + /// Strategy for resolving prayer times in polar regions where the sun may not rise or set + PolarCircleResolution polarCircleResolution; + /// Used to optionally add or subtract a set amount of time from each prayer time PrayerAdjustments adjustments; @@ -44,6 +48,7 @@ class CalculationParameters { this.ishaInterval = 0, this.madhab = Madhab.shafi, this.highLatitudeRule = HighLatitudeRule.middle_of_the_night, + this.polarCircleResolution = PolarCircleResolution.unresolved, PrayerAdjustments? adjustments, PrayerAdjustments? methodAdjustments}) : adjustments = adjustments ?? PrayerAdjustments(), diff --git a/lib/src/polar_circle_resolution.dart b/lib/src/polar_circle_resolution.dart new file mode 100644 index 0000000..25fa6ed --- /dev/null +++ b/lib/src/polar_circle_resolution.dart @@ -0,0 +1,90 @@ +import 'coordinates.dart'; +import 'internal/solar_time.dart'; + +/// Resolution strategies for handling prayer time calculations in polar regions +/// where the sun may not rise or set for extended periods. +enum PolarCircleResolution { + /// Find the nearest location (by latitude) where valid sunrise/sunset times can be calculated. + /// This strategy adjusts the latitude in 0.5° steps toward the equator until valid solar times are found. + aqrabBalad, + + /// Find the nearest date (forward or backward in time) where valid sunrise/sunset times can be calculated. + /// This strategy searches for dates when the original location experiences normal day/night cycles. + aqrabYaum, + + /// Leave prayer times undefined when they cannot be calculated due to polar conditions. + /// This maintains the original behavior before polar circle resolution was introduced. + unresolved +} + +/// Constants for polar circle resolution calculations +class _PolarCircleConstants { + /// Degrees to add/remove at each resolution step for AqrabBalad + static const double latitudeVariationStep = 0.5; + + /// Latitude threshold for polar circle calculations (65° based on midnight sun research) + static const double unsafeLatitude = 65.0; + + /// Maximum days to search for AqrabYaum (half a year for practicality) + static const int maxDaysToSearch = 182; +} + +/// Result of polar circle resolution containing resolved calculation data +class _PolarCircleResolutionResult { + final DateTime date; + final DateTime tomorrow; + final Coordinates coordinates; + final SolarTime solarTime; + final SolarTime tomorrowSolarTime; + + _PolarCircleResolutionResult({ + required this.date, + required this.tomorrow, + required this.coordinates, + required this.solarTime, + required this.tomorrowSolarTime, + }); +} + +/// Utility functions for polar circle resolution +class PolarCircleResolutionUtils { + /// Check if solar time values are valid (not NaN or infinite) + static bool isValidSolarTime(SolarTime solarTime) { + bool _valid(double value) => !(value.isInfinite || value.isNaN); + return _valid(solarTime.sunrise) && _valid(solarTime.sunset); + } + + /// Check if both current and tomorrow's solar times are valid + static bool isValidSolarTimePair(SolarTime solarTime, SolarTime tomorrowSolarTime) { + return isValidSolarTime(solarTime) && isValidSolarTime(tomorrowSolarTime); + } +} + +/// Constants for polar circle resolution calculations +class PolarCircleConstants { + /// Degrees to add/remove at each resolution step for AqrabBalad + static const double latitudeVariationStep = 0.5; + + /// Latitude threshold for polar circle calculations (65° based on midnight sun research) + static const double unsafeLatitude = 65.0; + + /// Maximum days to search for AqrabYaum (half a year for practicality) + static const int maxDaysToSearch = 182; +} + +/// Result of polar circle resolution containing resolved calculation data +class PolarCircleResolutionResult { + final DateTime date; + final DateTime tomorrow; + final Coordinates coordinates; + final SolarTime solarTime; + final SolarTime tomorrowSolarTime; + + PolarCircleResolutionResult({ + required this.date, + required this.tomorrow, + required this.coordinates, + required this.solarTime, + required this.tomorrowSolarTime, + }); +} \ No newline at end of file diff --git a/lib/src/polar_circle_resolvers.dart b/lib/src/polar_circle_resolvers.dart new file mode 100644 index 0000000..6ac0210 --- /dev/null +++ b/lib/src/polar_circle_resolvers.dart @@ -0,0 +1,102 @@ +import 'data/date_components.dart'; +import 'internal/solar_time.dart'; +import 'polar_circle_resolution.dart'; +import 'coordinates.dart'; + +/// Implementation of AqrabBalad resolution strategy +/// Finds the nearest location (by latitude) where valid sunrise/sunset times can be calculated +class _AqrabBaladResolver { + static PolarCircleResolutionResult? resolve( + Coordinates originalCoordinates, + DateTime date, + double latitude, + ) { + // Calculate solar times at the given latitude + final coordinates = Coordinates(latitude, originalCoordinates.longitude); + final solarTime = SolarTime(date, coordinates); + final tomorrow = date.add(Duration(days: 1)); + final tomorrowSolarTime = SolarTime(tomorrow, coordinates); + + // If solar times are invalid and we're still in polar region + if (!PolarCircleResolutionUtils.isValidSolarTimePair(solarTime, tomorrowSolarTime)) { + // Check if we've reached a safe latitude + if (latitude.abs() >= PolarCircleConstants.unsafeLatitude) { + // Recursively try 0.5° closer to equator + final newLatitude = latitude - (latitude >= 0 ? 1 : -1) * PolarCircleConstants.latitudeVariationStep; + return resolve(originalCoordinates, date, newLatitude); + } else { + // Reached safe latitude but still no valid solar times + return null; + } + } + + // Return resolved values + return PolarCircleResolutionResult( + date: date, + tomorrow: tomorrow, + coordinates: coordinates, + solarTime: solarTime, + tomorrowSolarTime: tomorrowSolarTime, + ); + } +} + +/// Implementation of AqrabYaum resolution strategy +/// Finds the nearest date (forward or backward in time) where valid sunrise/sunset times can be calculated +class _AqrabYaumResolver { + static PolarCircleResolutionResult? resolve( + Coordinates coordinates, + DateTime originalDate, + int daysAdded, + int direction, + ) { + // Safety check: don't search more than half a year + if (daysAdded > PolarCircleConstants.maxDaysToSearch) { + return null; + } + + // Try calculating for offset date + final testDate = originalDate.add(Duration(days: direction * daysAdded)); + final tomorrow = testDate.add(Duration(days: 1)); + final solarTime = SolarTime(testDate, coordinates); + final tomorrowSolarTime = SolarTime(tomorrow, coordinates); + + // If still invalid, reverse direction and continue searching + if (!PolarCircleResolutionUtils.isValidSolarTimePair(solarTime, tomorrowSolarTime)) { + // Determine next search parameters + final nextDaysAdded = daysAdded + (direction > 0 ? 0 : 1); + final nextDirection = -direction; + + return resolve(coordinates, originalDate, nextDaysAdded, nextDirection); + } + + // Return resolved values (using original date for consistency, but resolved solar times) + return PolarCircleResolutionResult( + date: originalDate, // Keep original date for prayer time calculation + tomorrow: originalDate.add(Duration(days: 1)), + coordinates: coordinates, + solarTime: solarTime, + tomorrowSolarTime: tomorrowSolarTime, + ); + } +} + +/// Main resolver that dispatches to the appropriate strategy +class PolarCircleResolver { + static PolarCircleResolutionResult? resolve( + PolarCircleResolution strategy, + Coordinates coordinates, + DateTime date, + ) { + switch (strategy) { + case PolarCircleResolution.aqrabBalad: + return _AqrabBaladResolver.resolve(coordinates, date, coordinates.latitude); + + case PolarCircleResolution.aqrabYaum: + return _AqrabYaumResolver.resolve(coordinates, date, 1, 1); + + case PolarCircleResolution.unresolved: + return null; + } + } +} \ No newline at end of file diff --git a/lib/src/prayer_times.dart b/lib/src/prayer_times.dart index 580bb13..80fc63a 100644 --- a/lib/src/prayer_times.dart +++ b/lib/src/prayer_times.dart @@ -8,6 +8,8 @@ import 'data/date_components.dart'; import 'data/time_components.dart'; import 'internal/solar_time.dart'; import 'madhab.dart'; +import 'polar_circle_resolution.dart'; +import 'polar_circle_resolvers.dart'; import 'prayer.dart'; class PrayerTimes { @@ -41,6 +43,12 @@ class PrayerTimes { final CalculationParameters calculationParameters; + late bool _polarResolutionApplied; + bool get polarResolutionApplied => _polarResolutionApplied; + + late PolarCircleResolution? _polarResolutionStrategy; + PolarCircleResolution? get polarResolutionStrategy => _polarResolutionStrategy; + /// Calculate PrayerTimes and Output Local Times By Default. /// If you provide utcOffset then it will Output UTC with Offset Applied Times. /// @@ -125,21 +133,85 @@ class PrayerTimes { final year = date.year; final dayOfYear = date.dayOfYear; - final solarTime = SolarTime(date, coordinates); + // Initialize solar time calculation + late SolarTime solarTime; + late SolarTime tomorrowSolarTime; + late DateTime tomorrow; + late Coordinates calculationCoordinates; + + // Apply polar circle resolution if needed + final initialSolarTime = SolarTime(date, coordinates); + final initialTomorrowSolarTime = SolarTime(date.add(Duration(days: 1)), coordinates); + + bool resolutionApplied = false; + PolarCircleResolution? usedStrategy; + + if (!PolarCircleResolutionUtils.isValidSolarTimePair(initialSolarTime, initialTomorrowSolarTime) && + calculationParameters.polarCircleResolution != PolarCircleResolution.unresolved) { + // Apply resolution strategy + final resolutionResult = PolarCircleResolver.resolve( + calculationParameters.polarCircleResolution, + coordinates, + date, + ); + + if (resolutionResult != null) { + solarTime = resolutionResult.solarTime; + tomorrowSolarTime = resolutionResult.tomorrowSolarTime; + tomorrow = resolutionResult.tomorrow; + calculationCoordinates = resolutionResult.coordinates; + resolutionApplied = true; + usedStrategy = calculationParameters.polarCircleResolution; + } else { + // Resolution failed, use original coordinates (will result in undefined times) + solarTime = initialSolarTime; + tomorrowSolarTime = initialTomorrowSolarTime; + tomorrow = date.add(Duration(days: 1)); + calculationCoordinates = coordinates; + } + } else { + // No resolution needed or configured + solarTime = initialSolarTime; + tomorrowSolarTime = initialTomorrowSolarTime; + tomorrow = date.add(Duration(days: 1)); + calculationCoordinates = coordinates; + } + + // Store resolution info in instance variables (using private fields and getters) + _polarResolutionApplied = resolutionApplied; + _polarResolutionStrategy = usedStrategy; var timeComponents = TimeComponents.fromDouble(solarTime.transit); final transit = timeComponents.dateComponents(date); - timeComponents = TimeComponents.fromDouble(solarTime.sunrise); - final sunriseComponents = timeComponents.dateComponents(date); + // Calculate sunrise and sunset + DateTime sunriseComponents; + DateTime sunsetComponents; - timeComponents = TimeComponents.fromDouble(solarTime.sunset); - final sunsetComponents = timeComponents.dateComponents(date); + if (_valid(solarTime.sunrise)) { + timeComponents = TimeComponents.fromDouble(solarTime.sunrise); + sunriseComponents = timeComponents.dateComponents(date); + } else { + // Sunrise cannot be calculated (polar night) + sunriseComponents = date; // Placeholder, will be handled by safe bounds + } - final tomorrow = date.add(Duration(days: 1)); - final tomorrowSolarTime = SolarTime(tomorrow, coordinates); - final tomorrowSunriseComponents = - TimeComponents.fromDouble(tomorrowSolarTime.sunrise); + if (_valid(solarTime.sunset)) { + timeComponents = TimeComponents.fromDouble(solarTime.sunset); + sunsetComponents = timeComponents.dateComponents(date); + } else { + // Sunset cannot be calculated (midnight sun) + sunsetComponents = date; // Placeholder, will be handled by safe bounds + } + + // Calculate tomorrow's sunrise for night length calculation + DateTime tomorrowSunriseComponents; + if (_valid(tomorrowSolarTime.sunrise)) { + tomorrowSunriseComponents = TimeComponents.fromDouble(tomorrowSolarTime.sunrise).dateComponents(tomorrow); + } else { + // Tomorrow's sunrise cannot be calculated, use approximation + tomorrowSunriseComponents = transit.add(Duration(hours: 24)); + } tempDhuhr = transit; tempSunrise = sunriseComponents; @@ -149,12 +221,12 @@ class PrayerTimes { tempAsr = timeComponents.dateComponents(date); // get night length - final tomorrowSunrise = tomorrowSunriseComponents.dateComponents(tomorrow); + final tomorrowSunrise = tomorrowSunriseComponents; final night = tomorrowSunrise.millisecondsSinceEpoch - sunsetComponents.millisecondsSinceEpoch; + // Handle Fajr calculation _value = solarTime.hourAngle(-calculationParameters.fajrAngle, false); - if (_valid(_value)) { timeComponents = TimeComponents.fromDouble(_value); tempFajr = timeComponents.dateComponents(date); @@ -188,6 +260,7 @@ class PrayerTimes { tempIsha = sunsetComponents .add(Duration(seconds: calculationParameters.ishaInterval * 60)); } else { + // Handle Isha calculation _value = solarTime.hourAngle(-calculationParameters.ishaAngle!, true); if (calculationParameters.ishaAngle != null && _valid(_value)) { @@ -221,6 +294,7 @@ class PrayerTimes { tempMaghrib = sunsetComponents; if (calculationParameters.maghribAngle != null) { + // Handle angle-based Maghrib calculation final angleBasedMaghrib = TimeComponents.fromDouble(solarTime.hourAngle( -1 * calculationParameters.maghribAngle!, true)) .dateComponents(date); diff --git a/test/polar_midnight_test.dart b/test/polar_midnight_test.dart new file mode 100644 index 0000000..736bc63 --- /dev/null +++ b/test/polar_midnight_test.dart @@ -0,0 +1,159 @@ +import 'package:adhan/adhan.dart'; +import 'package:test/test.dart'; + +void main() { + group('Polar Circle Resolution Tests', () { + test('Test AqrabBalad resolution for polar night', () { + // Test location in northern Norway during polar night + final tromso = Coordinates(69.6492, 18.9553); // Tromsø, Norway + final date = DateComponents(2023, 12, 21); // Winter solstice + final params = CalculationMethod.muslim_world_league.getParameters(); + params.polarCircleResolution = PolarCircleResolution.aqrabBalad; + + final prayerTimes = PrayerTimes(tromso, date, params); + + // Polar circle resolution should have been applied + expect(prayerTimes.polarResolutionApplied, isTrue); + expect(prayerTimes.polarResolutionStrategy, PolarCircleResolution.aqrabBalad); + + // Prayer times should still be calculated using resolved coordinates + expect(prayerTimes.fajr, isNotNull); + expect(prayerTimes.sunrise, isNotNull); + expect(prayerTimes.maghrib, isNotNull); + expect(prayerTimes.isha, isNotNull); + }); + + test('Test AqrabYaum resolution for midnight sun', () { + // Test location in northern Norway during midnight sun + final tromso = Coordinates(69.6492, 18.9553); // Tromsø, Norway + final date = DateComponents(2023, 6, 21); // Summer solstice + final params = CalculationMethod.muslim_world_league.getParameters(); + params.polarCircleResolution = PolarCircleResolution.aqrabYaum; + + final prayerTimes = PrayerTimes(tromso, date, params); + + // Polar circle resolution should have been applied + expect(prayerTimes.polarResolutionApplied, isTrue); + expect(prayerTimes.polarResolutionStrategy, PolarCircleResolution.aqrabYaum); + + // Prayer times should still be calculated using resolved dates + expect(prayerTimes.fajr, isNotNull); + expect(prayerTimes.sunrise, isNotNull); + expect(prayerTimes.maghrib, isNotNull); + expect(prayerTimes.isha, isNotNull); + }); + + test('Test unresolved strategy leaves times undefined', () { + // Test location in extreme polar conditions + final longyearbyen = Coordinates(78.2232, 15.6267); // Longyearbyen, Svalbard + final date = DateComponents(2023, 12, 21); // Polar night + final params = CalculationMethod.muslim_world_league.getParameters(); + params.polarCircleResolution = PolarCircleResolution.unresolved; + + final prayerTimes = PrayerTimes(longyearbyen, date, params); + + // No resolution should have been applied + expect(prayerTimes.polarResolutionApplied, isFalse); + expect(prayerTimes.polarResolutionStrategy, isNull); + + // Prayer times should still be calculated using high latitude rules + expect(prayerTimes.fajr, isNotNull); + expect(prayerTimes.sunrise, isNotNull); + expect(prayerTimes.maghrib, isNotNull); + expect(prayerTimes.isha, isNotNull); + }); + + test('Test normal location without polar conditions', () { + // Test location in normal latitude + final london = Coordinates(51.5074, -0.1278); // London, UK + final date = DateComponents(2023, 6, 21); + final params = CalculationMethod.muslim_world_league.getParameters(); + params.polarCircleResolution = PolarCircleResolution.aqrabBalad; + + final prayerTimes = PrayerTimes(london, date, params); + + // Should not apply polar circle resolution + expect(prayerTimes.polarResolutionApplied, isFalse); + expect(prayerTimes.polarResolutionStrategy, isNull); + + // All prayer times should be normally calculated + expect(prayerTimes.fajr, isNotNull); + expect(prayerTimes.sunrise, isNotNull); + expect(prayerTimes.maghrib, isNotNull); + expect(prayerTimes.isha, isNotNull); + }); + + test('Test AqrabBalad resolution with Antarctic location', () { + // Test location in Antarctic during polar day + final mcmurdo = Coordinates(-77.8419, 166.6682); // McMurdo Station, Antarctica + final date = DateComponents(2023, 12, 21); // Southern summer solstice + final params = CalculationMethod.muslim_world_league.getParameters(); + params.polarCircleResolution = PolarCircleResolution.aqrabBalad; + + final prayerTimes = PrayerTimes(mcmurdo, date, params); + + // Polar circle resolution should have been applied + expect(prayerTimes.polarResolutionApplied, isTrue); + expect(prayerTimes.polarResolutionStrategy, PolarCircleResolution.aqrabBalad); + + // Prayer times should still be calculated + expect(prayerTimes.fajr, isNotNull); + expect(prayerTimes.sunrise, isNotNull); + expect(prayerTimes.maghrib, isNotNull); + expect(prayerTimes.isha, isNotNull); + }); + + test('Test different resolution strategies produce valid times', () { + final extremeLocation = Coordinates(80.0, 0.0); // Extreme North Pole area + final date = DateComponents(2023, 6, 21); + final baseParams = CalculationMethod.muslim_world_league.getParameters(); + + // Test AqrabBalad + final aqrabBaladParams = CalculationParameters( + method: baseParams.method, + fajrAngle: baseParams.fajrAngle, + ishaInterval: baseParams.ishaInterval, + madhab: baseParams.madhab, + highLatitudeRule: baseParams.highLatitudeRule, + polarCircleResolution: PolarCircleResolution.aqrabBalad, + ishaAngle: baseParams.ishaAngle, + ); + + final aqrabBaladTimes = PrayerTimes(extremeLocation, date, aqrabBaladParams); + expect(aqrabBaladTimes.polarResolutionApplied, isTrue); + expect(aqrabBaladTimes.fajr, isNotNull); + + // Test AqrabYaum + final aqrabYaumParams = CalculationParameters( + method: baseParams.method, + fajrAngle: baseParams.fajrAngle, + ishaInterval: baseParams.ishaInterval, + madhab: baseParams.madhab, + highLatitudeRule: baseParams.highLatitudeRule, + polarCircleResolution: PolarCircleResolution.aqrabYaum, + ishaAngle: baseParams.ishaAngle, + ); + + final aqrabYaumTimes = PrayerTimes(extremeLocation, date, aqrabYaumParams); + expect(aqrabYaumTimes.polarResolutionApplied, isTrue); + expect(aqrabYaumTimes.fajr, isNotNull); + }); + + test('Test edge case at polar circle boundary', () { + // Test exactly at 65° boundary + final boundaryLocation = Coordinates(65.0, 0.0); + final date = DateComponents(2023, 12, 21); + final params = CalculationMethod.muslim_world_league.getParameters(); + params.polarCircleResolution = PolarCircleResolution.aqrabBalad; + + final prayerTimes = PrayerTimes(boundaryLocation, date, params); + + // Resolution may or may not be applied depending on exact solar calculations + // But prayer times should always be valid + expect(prayerTimes.fajr, isNotNull); + expect(prayerTimes.sunrise, isNotNull); + expect(prayerTimes.maghrib, isNotNull); + expect(prayerTimes.isha, isNotNull); + }); + }); +} \ No newline at end of file diff --git a/test/src/calculation_method_test.dart b/test/src/calculation_method_test.dart index a7666b1..c1dfc09 100644 --- a/test/src/calculation_method_test.dart +++ b/test/src/calculation_method_test.dart @@ -1,5 +1,3 @@ -// @dart=2.9 - import 'package:adhan/adhan.dart'; import 'package:dart_numerics/dart_numerics.dart'; import 'package:test/test.dart'; diff --git a/test/src/calculation_parameters_test.dart b/test/src/calculation_parameters_test.dart index 4f8700d..6746db8 100644 --- a/test/src/calculation_parameters_test.dart +++ b/test/src/calculation_parameters_test.dart @@ -1,5 +1,3 @@ -// @dart=2.9 - import 'package:adhan/adhan.dart'; import 'package:dart_numerics/dart_numerics.dart'; import 'package:test/test.dart'; diff --git a/test/src/data/calendar_util_test.dart b/test/src/data/calendar_util_test.dart index 5ff8057..11facc0 100644 --- a/test/src/data/calendar_util_test.dart +++ b/test/src/data/calendar_util_test.dart @@ -1,5 +1,4 @@ import 'package:adhan/adhan.dart'; -import 'package:adhan/src/data/calendar_util.dart'; import 'package:test/test.dart'; void main() { diff --git a/test/src/extensions/datetime_test.dart b/test/src/extensions/datetime_test.dart index 72ab106..fd76c65 100644 --- a/test/src/extensions/datetime_test.dart +++ b/test/src/extensions/datetime_test.dart @@ -1,4 +1,4 @@ -import 'package:adhan/src/extensions/datetime.dart'; +import 'package:adhan/adhan.dart'; import 'package:test/test.dart'; void main() { diff --git a/test/src/prayer_times_test.dart b/test/src/prayer_times_test.dart index c42aa9c..9ad7aff 100644 --- a/test/src/prayer_times_test.dart +++ b/test/src/prayer_times_test.dart @@ -1,11 +1,7 @@ -// @dart=2.9 - import 'dart:convert'; import 'dart:io'; import 'package:adhan/adhan.dart'; -import 'package:adhan/src/data/calendar_util.dart'; -import 'package:adhan/src/extensions/datetime.dart'; import 'package:enum_to_string/enum_to_string.dart'; import 'package:intl/intl.dart'; import 'package:path/path.dart'; diff --git a/test/src/qibla_test.dart b/test/src/qibla_test.dart index dc0228f..fc9c81e 100644 --- a/test/src/qibla_test.dart +++ b/test/src/qibla_test.dart @@ -1,5 +1,3 @@ -// @dart=2.9 - import 'package:adhan/adhan.dart'; import 'package:dart_numerics/dart_numerics.dart'; import 'package:test/test.dart';