From b89ec6f1554c8ae2e895bf80b8904f39dde48eee Mon Sep 17 00:00:00 2001 From: Moshe Dicker Date: Sat, 8 Jul 2023 22:23:19 -0400 Subject: [PATCH 1/6] Handle Incalculable Zmanim --- zmanim/astronomical_calendar.py | 7 +++++-- zmanim/util/geo_location.py | 22 ++++++++++++++------ zmanim/zmanim_calendar.py | 37 +++++++++++++++++++++++++-------- 3 files changed, 49 insertions(+), 17 deletions(-) diff --git a/zmanim/astronomical_calendar.py b/zmanim/astronomical_calendar.py index eb942a6..3d5ddfa 100644 --- a/zmanim/astronomical_calendar.py +++ b/zmanim/astronomical_calendar.py @@ -62,7 +62,7 @@ def utc_sunset(self, zenith: float) -> Optional[float]: def utc_sea_level_sunset(self, zenith: float) -> Optional[float]: return self.astronomical_calculator.utc_sunset(self._adjusted_date(), self.geo_location, zenith, adjust_for_elevation=False) - def temporal_hour(self, sunrise: Optional[datetime] = __sentinel, sunset: Optional[datetime] = __sentinel) -> Optional[float]: + def temporal_hour(self, sunrise: Optional[datetime] = __sentinel, sunset: Optional[datetime] = __sentinel) -> Optional[float]: # type: ignore if sunrise == self.__sentinel: sunrise = self.sea_level_sunrise() if sunset == self.__sentinel: @@ -79,7 +79,10 @@ def sun_transit(self) -> Optional[datetime]: sunset = self.sea_level_sunset() if sunrise is None or sunset is None: return None - noon_hour = (self.temporal_hour(sunrise, sunset) / self.HOUR_MILLIS) * 6.0 + temporal_hour = self.temporal_hour(sunrise, sunset) + if temporal_hour is None: + return None + noon_hour = (temporal_hour / self.HOUR_MILLIS) * 6.0 return sunrise + timedelta(noon_hour / 24.0) def _date_time_from_time_of_day(self, time_of_day: Optional[float], mode: str) -> Optional[datetime]: diff --git a/zmanim/util/geo_location.py b/zmanim/util/geo_location.py index 43547a8..b4a9cec 100644 --- a/zmanim/util/geo_location.py +++ b/zmanim/util/geo_location.py @@ -1,5 +1,5 @@ -from datetime import datetime -from typing import Optional +from datetime import datetime, tzinfo +from typing import Optional, Union from dateutil import tz @@ -62,13 +62,16 @@ def longitude(self, longitude): raise TypeError("input must be a number or a list in the format 'degrees,minutes,seconds,direction'") @property - def time_zone(self) -> tz.tzfile: + def time_zone(self) -> Union[tz.tzfile,tzinfo]: return self.__time_zone @time_zone.setter def time_zone(self, time_zone): if isinstance(time_zone, str): - self.__time_zone = tz.gettz(time_zone) + time_zone = tz.gettz(time_zone) + if time_zone is None: + raise ValueError("invalid time zone") + self.__time_zone = time_zone elif isinstance(time_zone, tz.tzfile): self.__time_zone = time_zone else: @@ -109,7 +112,14 @@ def local_mean_time_offset(self) -> float: def standard_time_offset(self) -> int: now = datetime.now(tz=self.time_zone) - return int((now.utcoffset() - now.dst()).total_seconds()) * 1000 + utcoffset = now.utcoffset() + dst = now.dst() + if utcoffset is None or dst is None: + raise ValueError("Could not determine time zone offset or DST") + return int((utcoffset - dst).total_seconds()) * 1000 def time_zone_offset_at(self, utc_time: datetime) -> float: - return utc_time.astimezone(self.time_zone).utcoffset().total_seconds() / 3600.0 + utcoffset = utc_time.astimezone(self.time_zone).utcoffset() + if utcoffset is None: + raise ValueError("Could not determine time zone offset") + return utcoffset.total_seconds() / 3600.0 diff --git a/zmanim/zmanim_calendar.py b/zmanim/zmanim_calendar.py index edc9e73..1872a7b 100644 --- a/zmanim/zmanim_calendar.py +++ b/zmanim/zmanim_calendar.py @@ -6,7 +6,7 @@ class ZmanimCalendar(AstronomicalCalendar): - def __init__(self, candle_lighting_offset: int = None, *args, **kwargs): + def __init__(self, candle_lighting_offset: Optional[int] = None, *args, **kwargs): super(ZmanimCalendar, self).__init__(*args, **kwargs) self.candle_lighting_offset = 18 if candle_lighting_offset is None else candle_lighting_offset self.use_elevation = False @@ -56,14 +56,22 @@ def chatzos(self) -> Optional[datetime]: def candle_lighting(self) -> Optional[datetime]: return self._offset_by_minutes(self.sea_level_sunset(), -self.candle_lighting_offset) - def sof_zman_shma(self, day_start: datetime, day_end: datetime) -> datetime: + def sof_zman_shma(self, day_start: datetime, day_end: datetime) -> Optional[datetime]: return self._shaos_into_day(day_start, day_end, 3) - def sof_zman_shma_gra(self) -> datetime: - return self.sof_zman_shma(self.elevation_adjusted_sunrise(), self.elevation_adjusted_sunset()) + def sof_zman_shma_gra(self) -> Optional[datetime]: + elevation_adjusted_sunrise = self.elevation_adjusted_sunrise() + elevation_adjusted_sunset = self.elevation_adjusted_sunset() + if elevation_adjusted_sunrise is None or elevation_adjusted_sunset is None: + return None + return self.sof_zman_shma(elevation_adjusted_sunrise, elevation_adjusted_sunset) - def sof_zman_shma_mga(self) -> datetime: - return self.sof_zman_shma(self.alos_72(), self.tzais_72()) + def sof_zman_shma_mga(self) -> Optional[datetime]: + alos_72 = self.alos_72() + tzais_72 = self.tzais_72() + if alos_72 is None or tzais_72 is None: + return None + return self.sof_zman_shma(alos_72, tzais_72) def sof_zman_tfila(self, day_start: Optional[datetime], day_end: Optional[datetime]) -> Optional[datetime]: return self._shaos_into_day(day_start, day_end, 4) @@ -111,17 +119,25 @@ def shaah_zmanis_by_degrees_and_offset(self, degrees: float, offset: float) -> O opts = {'degrees': degrees, 'offset': offset} return self.shaah_zmanis(self.alos(opts), self.tzais(opts)) - def is_assur_bemelacha(self, current_time: datetime, tzais=None, in_israel: Optional[bool]=False): + def is_assur_bemelacha(self, current_time: datetime, tzais=None, in_israel: Optional[bool]=False) -> Optional[bool]: if tzais is None: tzais_time = self.tzais() elif isinstance(tzais, dict): tzais_time = self.tzais(tzais) else: tzais_time = tzais + + if tzais_time is None: + return None + + elevation_adjusted_sunset = self.elevation_adjusted_sunset() + if elevation_adjusted_sunset is None: + return None + jewish_calendar = JewishCalendar(current_time.date()) jewish_calendar.in_israel = in_israel return (current_time <= tzais_time and jewish_calendar.is_assur_bemelacha()) or \ - (current_time >= self.elevation_adjusted_sunset() and jewish_calendar.is_tomorrow_assur_bemelacha()) + (current_time >= elevation_adjusted_sunset and jewish_calendar.is_tomorrow_assur_bemelacha()) def _shaos_into_day(self, day_start: Optional[datetime], day_end: Optional[datetime], shaos: float) -> Optional[datetime]: shaah_zmanis = self.temporal_hour(day_start, day_end) @@ -143,5 +159,8 @@ def _offset_by_minutes(self, time: Optional[datetime], minutes: float) -> Option def _offset_by_minutes_zmanis(self, time: Optional[datetime], minutes: float) -> Optional[datetime]: if time is None: return None - shaah_zmanis_skew = self.shaah_zmanis_gra() / self.HOUR_MILLIS + shaah_zmanis_gra = self.shaah_zmanis_gra() + if shaah_zmanis_gra is None: + return None + shaah_zmanis_skew = shaah_zmanis_gra / self.HOUR_MILLIS return time + timedelta(minutes=minutes*shaah_zmanis_skew) From 47854f1cd89a51f9b9686fdb4d2184ade8368dab Mon Sep 17 00:00:00 2001 From: Moshe Dicker Date: Sat, 8 Jul 2023 22:23:50 -0400 Subject: [PATCH 2/6] Make `AstronomicalCalculations` an abstract class --- zmanim/util/astronomical_calculations.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/zmanim/util/astronomical_calculations.py b/zmanim/util/astronomical_calculations.py index 94c3a29..dc3c7ca 100644 --- a/zmanim/util/astronomical_calculations.py +++ b/zmanim/util/astronomical_calculations.py @@ -1,7 +1,13 @@ import math +from datetime import date +from typing import Optional +from abc import ABC, abstractmethod +from zmanim.util.geo_location import GeoLocation -class AstronomicalCalculations: + + +class AstronomicalCalculations(ABC): GEOMETRIC_ZENITH = 90.0 def __init__(self): @@ -15,4 +21,12 @@ def elevation_adjustment(self, elevation: float) -> float: def adjusted_zenith(self, zenith: float, elevation: float) -> float: if zenith != self.GEOMETRIC_ZENITH: return zenith - return zenith + self.solar_radius + self.refraction + self.elevation_adjustment(elevation) \ No newline at end of file + return zenith + self.solar_radius + self.refraction + self.elevation_adjustment(elevation) + + @abstractmethod + def utc_sunrise(self, target_date: date, geo_location: GeoLocation, zenith: float, adjust_for_elevation: bool = False) -> Optional[float]: + pass + + @abstractmethod + def utc_sunset(self, target_date: date, geo_location: GeoLocation, zenith: float, adjust_for_elevation: bool = False) -> Optional[float]: + pass \ No newline at end of file From 5b29dc113b340870c003899c4689c46137323a99 Mon Sep 17 00:00:00 2001 From: Moshe Dicker Date: Sat, 8 Jul 2023 22:35:50 -0400 Subject: [PATCH 3/6] Fixes in Jewish Date Fix duplicate `date` Backward compatible typing for Tuple Correct typing for `cheshvan_kislev_kviah` method --- zmanim/hebrew_calendar/jewish_date.py | 35 ++++++++++++++------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/zmanim/hebrew_calendar/jewish_date.py b/zmanim/hebrew_calendar/jewish_date.py index 7e749dd..8259ea2 100644 --- a/zmanim/hebrew_calendar/jewish_date.py +++ b/zmanim/hebrew_calendar/jewish_date.py @@ -1,15 +1,16 @@ import copy -from datetime import date, timedelta +from datetime import timedelta +from datetime import date as dt_date from enum import Enum from memoization import cached -from typing import Optional +from typing import Optional, Tuple class JewishDate: MONTHS = Enum('Months', 'nissan iyar sivan tammuz av elul tishrei cheshvan kislev teves shevat adar adar_ii') MONTHS_LIST = list(MONTHS) - RD = date(1, 1, 1) + RD = dt_date(1, 1, 1) JEWISH_EPOCH = -1373429 CHALAKIM_PER_MINUTE = 18 @@ -26,7 +27,7 @@ def __init__(self, *args, **kwargs): self.reset_date() elif len(args) == 3: self.set_jewish_date(*args, **kwargs) - elif len(args) == 1 and isinstance(args[0], date): + elif len(args) == 1 and isinstance(args[0], dt_date): self.date = args[0] elif len(args) == 1 and isinstance(args[0], int): self._set_from_molad(*args) @@ -39,7 +40,7 @@ def __repr__(self): self.jewish_date, self.day_of_week, self.molad_hours, self.molad_minutes, self.molad_chalakim) @property - def gregorian_date(self) -> date: + def gregorian_date(self) -> dt_date: return self.__gregorian_date @property @@ -71,7 +72,7 @@ def day_of_week(self) -> int: return self.__day_of_week @property - def jewish_date(self) -> (int, int, int): + def jewish_date(self) -> Tuple[int, int, int]: return self.__jewish_year, self.__jewish_month, self.__jewish_day @property @@ -131,11 +132,11 @@ def from_jewish_date(cls, year: int, month: int, date: int) -> 'JewishDate': return cls(year, month, date) @classmethod - def from_date(cls, date: date) -> 'JewishDate': + def from_date(cls, date: dt_date) -> 'JewishDate': return cls(date) def reset_date(self) -> 'JewishDate': - self.date = date.today() + self.date = dt_date.today() return self def set_jewish_date(self, year: int, month: int, day: int, hours: int = 0, minutes: int = 0, chalakim: int = 0): @@ -159,7 +160,7 @@ def set_gregorian_date(self, year: int, month: int, day: int): raise ValueError("invalid date parts") max_days = self.days_in_gregorian_month(month, year) day = max_days if day > max_days else day - self.date = date(year, month, day) + self.date = dt_date(year, month, day) def forward(self, increment: int = 1) -> 'JewishDate': if increment < 0: @@ -237,7 +238,7 @@ def __sub__(self, subtrahend): return type(self)(self.gregorian_date - subtrahend) elif isinstance(subtrahend, JewishDate): return self.gregorian_date - subtrahend.gregorian_date - elif isinstance(subtrahend, date): + elif isinstance(subtrahend, dt_date): return self.gregorian_date - subtrahend raise ValueError @@ -366,7 +367,7 @@ def is_jewish_leap_year(self, year: Optional[int] = None) -> bool: year = self.jewish_year return self._is_jewish_leap_year(year) - def cheshvan_kislev_kviah(self, year: Optional[int] = None) -> str: + def cheshvan_kislev_kviah(self, year: Optional[int] = None) -> CHESHVAN_KISLEV_KEVIAH: if year is None: year = self.jewish_year year_type = (self.days_in_jewish_year(year) % 10) - 3 @@ -382,7 +383,7 @@ def kviah(self, year: Optional[int] = None) -> tuple: pesach_day = date.day_of_week return rosh_hashana_day, kviah_value, pesach_day - def molad(self, month: int = None, year: Optional[int] = None) -> 'JewishDate': + def molad(self, month: Optional[int] = None, year: Optional[int] = None) -> 'JewishDate': if month is None: month = self.jewish_month if year is None: @@ -419,7 +420,7 @@ def _jewish_date_to_abs_date(self, year: int, month: int, day: int) -> int: return self.day_number_of_jewish_year(year, month, day) + \ self._jewish_year_start_to_abs_date(year) - 1 - def _jewish_date_from_abs_date(self, absolute_date: int) -> (int, int, int): + def _jewish_date_from_abs_date(self, absolute_date: int) -> Tuple[int, int, int]: jewish_year = int((absolute_date - self.JEWISH_EPOCH) / 366) # estimate may be low for CE @@ -437,11 +438,11 @@ def _jewish_date_from_abs_date(self, absolute_date: int) -> (int, int, int): return jewish_year, jewish_month, jewish_day - def _gregorian_date_to_abs_date(self, gregorian_date: date) -> int: + def _gregorian_date_to_abs_date(self, gregorian_date: dt_date) -> int: return gregorian_date.toordinal() - def _gregorian_date_from_abs_date(self, absolute_date: int) -> date: - return date.fromordinal(absolute_date) + def _gregorian_date_from_abs_date(self, absolute_date: int) -> dt_date: + return dt_date.fromordinal(absolute_date) def _molad_to_abs_date(self, chalakim: int) -> int: return int(chalakim / self.CHALAKIM_PER_DAY) + self.JEWISH_EPOCH @@ -516,7 +517,7 @@ def _dechiyos_count(year: int, days: int, remainder: int) -> int: return count @staticmethod - def _molad_components_for_year(year: int) -> (int, int): + def _molad_components_for_year(year: int) -> Tuple[int, int]: chalakim = JewishDate._chalakim_since_molad_tohu(year, 7) # chalakim up to tishrei of given year days, remainder = divmod(chalakim, JewishDate.CHALAKIM_PER_DAY) return int(days), int(remainder) From 5fc3e38fbfc1a5645c062fd8467c8f2b46a8ce64 Mon Sep 17 00:00:00 2001 From: Moshe Dicker Date: Sat, 8 Jul 2023 23:21:47 -0400 Subject: [PATCH 4/6] Tests for Incalculable Zmanim --- test/test_astronomical_calendar.py | 8 ++++++++ test/test_zmanim_calendar.py | 31 ++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/test/test_astronomical_calendar.py b/test/test_astronomical_calendar.py index 137ab21..cba4454 100644 --- a/test/test_astronomical_calendar.py +++ b/test/test_astronomical_calendar.py @@ -209,6 +209,14 @@ def test_entry(geo): for entry in expected: self.assertEqual(test_entry(entry[0]), entry) + + def test_temporal_hour_on_day_without_sunset_or_without_sunrise(self): + calc = AstronomicalCalendar(date=date(2023, 6, 20)) # Middle of the North Pole summer + calc.geo_location = test_helper.daneborg() + self.assertIsNone(calc.temporal_hour()) + calc = AstronomicalCalendar(date=date(2023, 1, 20)) # Middle of the North Pole winter + calc.geo_location = test_helper.daneborg() + self.assertIsNone(calc.temporal_hour()) if __name__ == '__main__': diff --git a/test/test_zmanim_calendar.py b/test/test_zmanim_calendar.py index b0f7bd8..78f4053 100644 --- a/test/test_zmanim_calendar.py +++ b/test/test_zmanim_calendar.py @@ -334,6 +334,37 @@ def test_assur_bemelacha_on_first_of_two_issur_melacha_days_in_israel(self): calendar = ZmanimCalendar(geo_location=test_helper.lakewood(), date=parser.parse(date)) self.assertTrue(calendar.is_assur_bemelacha(calendar.tzais() - timedelta(seconds=2), in_israel=True)) self.assertTrue(calendar.is_assur_bemelacha(calendar.tzais() + timedelta(seconds=2), in_israel=True)) + + def test_sof_zman_shma_gra_on_day_without_sunset_or_without_sunrise(self): + date = '2023-06-20' # Middle of the North Pole summer + calendar = ZmanimCalendar(geo_location=test_helper.daneborg(), date=parser.parse(date)) + self.assertEqual(calendar.sof_zman_shma_gra(), None) + + date = '2023-01-20' # Middle of the North Pole Winter + calendar = ZmanimCalendar(geo_location=test_helper.daneborg(), date=parser.parse(date)) + self.assertEqual(calendar.sof_zman_shma_gra(), None) + + def test_sof_zman_shma_mga_on_day_without_sunset_or_without_sunrise(self): + date = '2023-06-20' # Middle of the North Pole summer + calendar = ZmanimCalendar(geo_location=test_helper.daneborg(), date=parser.parse(date)) + self.assertEqual(calendar.sof_zman_shma_mga(), None) + + date = '2023-01-20' # Middle of the North Pole Winter + calendar = ZmanimCalendar(geo_location=test_helper.daneborg(), date=parser.parse(date)) + self.assertEqual(calendar.sof_zman_shma_mga(), None) + + def test__offset_by_minutes_zmanis_on_day_without_sunset_or_without_sunrise(self): + date = '2023-06-20' # Middle of the North Pole summer + date_object = parser.parse(date) + calendar = ZmanimCalendar(geo_location=test_helper.daneborg(), date=date_object) + self.assertEqual(calendar._offset_by_minutes_zmanis(date_object,10), None) + + date = '2023-01-20' # Middle of the North Pole Winter + date_object = parser.parse(date) + calendar = ZmanimCalendar(geo_location=test_helper.daneborg(), date=date_object) + self.assertEqual(calendar._offset_by_minutes_zmanis(date_object,10), None) + + if __name__ == '__main__': From c6711f60e66733f4b6588d0c1e6b37ab9c0cc9b0 Mon Sep 17 00:00:00 2001 From: Moshe Dicker <75931499+dickermoshe@users.noreply.github.com> Date: Sun, 9 Jul 2023 14:46:09 -0400 Subject: [PATCH 5/6] Create test.yml --- .github/workflows/test.yml | 42 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..97a668f --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,42 @@ +# This workflow will install Python dependencies, run tests and lint with a variety of Python versions +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python + +name: Python package + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + workflow_dispatch: + + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.7","3.8", "3.9", "3.10"] + + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install flake8 pytest + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Test with pytest + run: | + pytest From a7eebfe47a75a8dc31e144b7b5a9dfaf42c8b356 Mon Sep 17 00:00:00 2001 From: Moshe Dicker <75931499+dickermoshe@users.noreply.github.com> Date: Sun, 9 Jul 2023 14:47:36 -0400 Subject: [PATCH 6/6] Update test.yml --- .github/workflows/test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 97a668f..1128e52 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,6 +31,7 @@ jobs: python -m pip install --upgrade pip python -m pip install flake8 pytest if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + python setup.py install - name: Lint with flake8 run: | # stop the build if there are Python syntax errors or undefined names