Skip to content

Commit

Permalink
Merge pull request #195 from aridevelopment-de/feature/suntime
Browse files Browse the repository at this point in the history
Constant evaluation based on coordinates and timezone
  • Loading branch information
Inf-inity committed Oct 29, 2022
2 parents 0c067b5 + 4a2449e commit 7ee7c1c
Show file tree
Hide file tree
Showing 11 changed files with 184 additions and 31 deletions.
22 changes: 16 additions & 6 deletions README.md
Expand Up @@ -29,17 +29,27 @@ Below you can find some examples of how datetimeparser can be used.
```python
from datetimeparser import parse

print(parse("next 3 years and 2 months"))
# 2025-04-06 11:43:28
print(parse("next 3 years and 2 months").time)
# 2025-12-28 11:57:25

print(parse("begin of advent of code 2022"))
print(parse("begin of advent of code 2022").time)
# 2022-12-01 06:00:00

print(parse("in 1 Year 2 months 3 weeks 4 days 5 hours 6 minutes 7 seconds"))
# 2023-05-01 17:59:52
print(parse("in 1 Year 2 months 3 weeks 4 days 5 hours 6 minutes 7 seconds").time)
# 2024-01-22 17:04:26

print(parse("10 days and 2 hours after 3 months before christmas 2020"))
print(parse("10 days and 2 hours after 3 months before christmas 2020").time)
# 2020-10-05 02:00:00

print(parse("sunrise"))
# <Result: time='2022-10-28 08:15:19', timezone='Europe/Berlin', coordinates=[longitude='7.188402', latitude='50.652927999999996]'>

print(parse("sunrise", timezone="Asia/Dubai"))
# <Result: time='2022-10-28 06:23:17', timezone='Asia/Dubai', coordinates=[longitude='55.455098', latitude='25.269651999999997]'>

# https://www.timeanddate.com/sun/japan/tokyo states that the sunset today (2022-10-28) is at '16:50' in Tokyo
print(parse("sunset", coordinates=(139.839478, 35.652832))) # (Tokyo in Japan)
# <Result: time='2022-10-28 16:50:04', timezone='Asia/Tokyo', coordinates=[longitude='139.839478', latitude='35.652832]'>
```

## Installation
Expand Down
22 changes: 15 additions & 7 deletions datetimeparser/datetimeparser.py
Expand Up @@ -2,34 +2,42 @@
Main module which provides the parse function.
"""

__all__ = ['parse', '__version__', '__author__']
__version__ = "0.13.5"
__all__ = ['parse', 'Result', '__version__', '__author__']
__version__ = "0.14.0"
__author__ = "aridevelopment"

import datetime
from typing import Optional

from datetimeparser.evaluator import Evaluator
from datetimeparser.parser import Parser
from datetimeparser.utils.models import Result


def parse(datetime_string: str, timezone: str = "Europe/Berlin") -> Optional[datetime.datetime]:
def parse(
datetime_string: str,
timezone: str = "Europe/Berlin",
coordinates: Optional[tuple[float, float]] = None
) -> Optional[Result]:
"""
Parses a datetime string and returns a datetime object.
If the datetime string cannot be parsed, None is returned.
:param datetime_string: The datetime string to parse.
:param timezone: The timezone to use. Should be a valid timezone for pytz.timezone(). Default: Europe/Berlin
:return: A datetime object or None
:param coordinates: A tuple containing longitude and latitude. If coordinates are given, the timezone will be calculated,
independently of the given timezone param.
NOTE: It can take some seconds until a result is returned
:return: A result object containing the returned time, the timezone and optional coordinates.
If the process fails, None will be returned
"""
parser_result = Parser(datetime_string).parse()

if parser_result is None:
return None

evaluator_result = Evaluator(parser_result, tz=timezone).evaluate()
evaluator_result, tz, coordinates = Evaluator(parser_result, tz=timezone, coordinates=coordinates).evaluate()

if evaluator_result is None:
return None

return evaluator_result
return Result(evaluator_result, tz, coordinates)
21 changes: 14 additions & 7 deletions datetimeparser/evaluator/evaluator.py
@@ -1,20 +1,24 @@
from datetime import datetime
from pytz import timezone, UnknownTimeZoneError
from typing import Union
from typing import Optional, Union

from datetimeparser.utils.baseclasses import AbsoluteDateTime, RelativeDateTime
from datetimeparser.utils.enums import Method
from datetimeparser.evaluator.evaluatormethods import EvaluatorMethods
from datetimeparser.utils.exceptions import FailedEvaluation, InvalidValue
from datetimeparser.utils.geometry import TimeZoneManager


class Evaluator:
def __init__(self, parsed_object, tz="Europe/Berlin"):
def __init__(self, parsed_object, tz="Europe/Berlin", coordinates: Optional[tuple[float, float]] = None):
"""
:param parsed_object: the parsed object from parser
:param tz: the timezone for the datetime
:param coordinates: longitude and latitude for timezone calculation and for sunrise and sunset
"""

if coordinates:
tz = TimeZoneManager().timezone_at(lng=coordinates[0], lat=coordinates[1])
try:
tiz = timezone(tz)
except UnknownTimeZoneError:
Expand All @@ -24,10 +28,13 @@ def __init__(self, parsed_object, tz="Europe/Berlin"):
self.parsed_object_content: Union[list, AbsoluteDateTime, RelativeDateTime] = parsed_object[1]
self.current_datetime: datetime = datetime.strptime(datetime.strftime(datetime.now(tz=tiz), "%Y-%m-%d %H:%M:%S"), "%Y-%m-%d %H:%M:%S")
self.offset = tiz.utcoffset(self.current_datetime)
self.timezone = tiz
self.coordinates = coordinates

def evaluate(self) -> Union[datetime, None]:
ev_out = None
ev = EvaluatorMethods(self.parsed_object_content, self.current_datetime, self.offset)
def evaluate(self) -> Union[tuple[datetime, str, tuple[float, float]], None]:
ev_out: Optional[datetime] = None
coordinates: Optional[tuple[float, float]] = None
ev = EvaluatorMethods(self.parsed_object_content, self.current_datetime, self.timezone.zone, self.coordinates, self.offset)

if self.parsed_object_type == Method.ABSOLUTE_DATE_FORMATS:
ev_out = ev.evaluate_absolute_date_formats()
Expand All @@ -36,7 +43,7 @@ def evaluate(self) -> Union[datetime, None]:
ev_out = ev.evaluate_absolute_prepositions()

if self.parsed_object_type == Method.CONSTANTS:
ev_out = ev.evaluate_constants()
ev_out, coordinates = ev.evaluate_constants()

if self.parsed_object_type == Method.RELATIVE_DATETIMES:
ev_out = ev.evaluate_relative_datetime()
Expand All @@ -48,6 +55,6 @@ def evaluate(self) -> Union[datetime, None]:
ev_out = ev.evaluate_datetime_delta_constants()

if ev_out:
return ev_out
return ev_out, self.timezone.zone, self.coordinates or coordinates
else:
raise FailedEvaluation(self.parsed_object_content)
34 changes: 29 additions & 5 deletions datetimeparser/evaluator/evaluatormethods.py
@@ -1,24 +1,34 @@
from typing import Any, Optional

from datetimeparser.evaluator.evaluatorutils import EvaluatorUtils
from datetimeparser.utils.baseclasses import *
from datetimeparser.utils.enums import *
from datetimeparser.utils.exceptions import InvalidValue
from datetimeparser.utils.formulars import calc_sun_time
from datetimeparser.utils.geometry import TimeZoneManager


class EvaluatorMethods(EvaluatorUtils):
"""
Evaluates a datetime-object from a given list returned from the parser
"""

def __init__(self, parsed, current_time: datetime, offset: timedelta = None):
def __init__(
self, parsed: Any, current_time: datetime, timezone: str, coordinates: Optional[tuple[float, float]], offset: timedelta = None
):
"""
:param parsed: object returned from the parser
:param current_time: the current datetime
:param timezone: the given timezone
:param coordinates: coordinates from the timezone
:param offset: the UTC-offset from the current timezone. Default: None
"""

self.parsed = parsed
self.current_time = current_time
self.offset = offset
self.coordinates = coordinates
self.timezone = timezone

def evaluate_absolute_date_formats(self) -> datetime:
ev_out = datetime(
Expand Down Expand Up @@ -100,7 +110,7 @@ def evaluate_absolute_prepositions(self) -> datetime:

return base

def evaluate_constants(self) -> datetime:
def evaluate_constants(self) -> tuple[datetime, Optional[tuple[float, float]]]:
dt: datetime = self.current_time
object_type: Constant = self.parsed[0]

Expand All @@ -122,6 +132,18 @@ def evaluate_constants(self) -> datetime:
"%Y-%m-%d %H:%M:%S"
)

elif object_type.name == "sunset" or object_type.name == "sunrise":
ofs = self.offset.total_seconds() / 60 / 60 # -> to hours
# TODO: at the moment summer and winter time change the result for the offset around 1 hour
if not self.coordinates:
self.coordinates = TimeZoneManager().get_coordinates(self.timezone)

dt = calc_sun_time(
self.current_time,
(self.coordinates[0], self.coordinates[1], ofs),
object_type.name == "sunrise"
)

else:
dt = object_type.time_value(self.current_time.year)
if isinstance(dt, tuple):
Expand All @@ -133,9 +155,11 @@ def evaluate_constants(self) -> datetime:
minute=dt[1],
second=dt[2]
)
return dt
return dt, self.coordinates

if self.current_time >= dt and self.parsed[0] not in (Constants.ALL_RELATIVE_CONSTANTS and WeekdayConstants.ALL):
if self.current_time >= dt and self.parsed[0] not in (
Constants.ALL_RELATIVE_CONSTANTS and WeekdayConstants.ALL and DatetimeDeltaConstants.CHANGING
):
dt = object_type.time_value(self.current_time.year + 1)

if self.current_time >= dt and self.parsed[0] in WeekdayConstants.ALL:
Expand All @@ -148,7 +172,7 @@ def evaluate_constants(self) -> datetime:
if object_type.offset:
ev_out = self.add_relative_delta(ev_out, self.get_offset(object_type, self.offset), self.current_time)

return ev_out
return ev_out, self.coordinates

def evaluate_relative_datetime(self) -> datetime:
out: datetime = self.current_time
Expand Down
2 changes: 1 addition & 1 deletion datetimeparser/evaluator/evaluatorutils.py
Expand Up @@ -109,7 +109,7 @@ def sanitize_input(
current_time
)
if current_time > test_out and not given_year:
parsed_list = EvaluatorUtils.x_week_of_month(relative_dt, idx, pars2, year+1)
parsed_list = EvaluatorUtils.x_week_of_month(relative_dt, idx, pars2, year + 1)

return list(filter(lambda e: e not in Keywords.ALL and not isinstance(e, str), parsed_list)), given_year

Expand Down
7 changes: 5 additions & 2 deletions datetimeparser/utils/enums.py
Expand Up @@ -119,7 +119,7 @@ class DatetimeDeltaConstants:
DAYLIGHT_CHANGE = Constant('daylight change', ['daylight saving', 'daylight saving time'], value=0,
options=[ConstantOption.YEAR_VARIABLE, ConstantOption.DATE_VARIABLE],
time_value=lambda _: (6, 0, 0))
SUNRISE = Constant('sunrise', value=0, options=[ConstantOption.DATE_VARIABLE], time_value=lambda _: (7, 0, 0))
SUNRISE = Constant('sunrise', value=0, options=[ConstantOption.DATE_VARIABLE], time_value=lambda _: None)
MORNING = Constant('morning', value=0, options=[ConstantOption.DATE_VARIABLE], time_value=lambda _: (6, 0, 0))
BREAKFAST = Constant('breakfast', value=0, options=[ConstantOption.DATE_VARIABLE], time_value=lambda _: (8, 0, 0))

Expand All @@ -132,12 +132,15 @@ class DatetimeDeltaConstants:
time_value=lambda _: (19, 0, 0))
DAWN = Constant('dawn', value=12, options=[ConstantOption.DATE_VARIABLE], time_value=lambda _: (6, 0, 0))
DUSK = Constant('dusk', value=12, options=[ConstantOption.DATE_VARIABLE], time_value=lambda _: (20, 0, 0))
SUNSET = Constant('sunset', value=12, options=[ConstantOption.DATE_VARIABLE], time_value=lambda _: (18, 30, 0))
SUNSET = Constant('sunset', value=12, options=[ConstantOption.DATE_VARIABLE], time_value=lambda _: None)

ALL = [
MORNING, AFTERNOON, EVENING, NIGHT, MORNING_NIGHT, DAYLIGHT_CHANGE, MIDNIGHT, MIDDAY, DAWN, DUSK,
SUNRISE, SUNSET, LUNCH, DINNER, BREAKFAST
]
CHANGING = [
SUNRISE, SUNSET
]


class NumberConstants:
Expand Down
47 changes: 47 additions & 0 deletions datetimeparser/utils/formulars.py
@@ -1,4 +1,13 @@
from datetime import datetime, timedelta
import math


def day_of_year(dt: datetime) -> int:
n1 = math.floor(275 * dt.month / 9)
n2 = math.floor((dt.month + 9) / 12)
n3 = (1 + math.floor((dt.year - 4 * math.floor(dt.year / 4) + 2) / 3))

return n1 - (n2 * n3) + dt.day - 30


def eastern_calc(year_time: int) -> datetime:
Expand Down Expand Up @@ -34,3 +43,41 @@ def days_feb(year_time: int) -> int:

def year_start(year_time: int) -> datetime:
return datetime(year=year_time, month=1, day=1)


def calc_sun_time(dt: datetime, timezone: tuple[float, float, float], sunrise: bool = True) -> datetime:
"""
Calculates the time for sunrise and sunset based on coordinates and a date
:param dt: The date for calculating the sunset
:param timezone: A tuple with longitude and latitude and timezone offset
:param sunrise: If True the sunrise will be calculated if False the sunset
:returns: The time for the sunrise/sunset
"""

to_rad: float = math.pi / 180
day: int = day_of_year(dt)
longitude_to_hour = timezone[0] / 15
b = timezone[1] * to_rad
h = -50 * to_rad / 60

time_equation = -0.171 * math.sin(0.0337 * day + 0.465) - 0.1299 * math.sin(0.01787 * day - 0.168)
declination = 0.4095 * math.sin(0.016906 * (day - 80.086))

time_difference = 12 * math.acos((math.sin(h) - math.sin(b) * math. sin(declination)) / (math.cos(b) * math.cos(declination))) / math.pi

if sunrise: # woz -> True time at location
woz = 12 - time_difference
else:
woz = 12 + time_difference

time: float = (woz - time_equation) - longitude_to_hour + timezone[2]

hour: int = int(time)
minutes_left: float = time - int(time)
minutes_with_seconds = minutes_left * 60
minute: int = int(minutes_with_seconds)
second: int = int((minutes_with_seconds - minute) * 60)

out: datetime = datetime(year=dt.year, month=dt.month, day=dt.day, hour=hour, minute=minute, second=second)

return out
22 changes: 22 additions & 0 deletions datetimeparser/utils/geometry.py
@@ -0,0 +1,22 @@
from timezonefinder import TimezoneFinder


class TimeZoneManager(TimezoneFinder):

def __init__(self):
super(TimeZoneManager, self).__init__(in_memory=True)

def get_coordinates(self, timezone: str) -> tuple[float, float]:
coords = self.get_geometry(tz_name=timezone, coords_as_pairs=True)

while not isinstance(coords[0], tuple):
coords = coords[len(coords) // 2]

coords: tuple[float, float] = coords[len(coords) // 2]

# timezone = self.timezone_at(lng=coords[0] + 1, lat=coords[1])
# TODO: needs to be improved, at the moment it's just a small fix, not tested if it works with all timezones
# TODO: add testcases for ALL timezones if possible to check if the "+1" fix is working
# at the moment it returns "Europe/Belgium" if the timezone "Europe/Berlin" is used -> the "+1" on longitude fixes that

return coords[0] + 1, coords[1]
30 changes: 30 additions & 0 deletions datetimeparser/utils/models.py
@@ -0,0 +1,30 @@
from datetime import datetime


class Result:
"""
The returned Result by the parse function, containing the output information
- Attributes:
- time (datetime): The parsed time
- timezone (str): The used timezone
- coordinates (Optional[tuple[float, float]]): Coordinates used for parsing
"""
time: datetime
timezone: str
coordinates: tuple[float, float]

def __init__(self, time: datetime, timezone: str, coordinates: tuple[float, float] = None):
self.time = time
self.timezone = timezone
self.coordinates = coordinates

def __repr__(self):
out: str = "'None'"
if self.coordinates:
out: str = f"[longitude='{self.coordinates[0]}', latitude='{self.coordinates[1]}]'"
return f"<Result: time='{self.time}', timezone='{self.timezone}', coordinates={out}>"

def __str__(self):
return self.__repr__()
3 changes: 2 additions & 1 deletion requirements.txt
@@ -1,3 +1,4 @@
python-dateutil
pytz
typing
typing
timezonefinder
5 changes: 3 additions & 2 deletions tests/runtests.py
Expand Up @@ -45,10 +45,11 @@ def get_testcase_results(testcase: str, expected_value: datetime.datetime = None
if parser_result is None:
return StatusType.PARSER_RETURNS_NONE, None

evaluator = Evaluator(parser_result, tz="Europe/Berlin")
evaluator = Evaluator(parser_result, tz="Europe/Berlin", coordinates=None)
# Berlin (13.41053, 52.52437), Dubai (55.2962, 25.2684)

try:
evaluator_result = evaluator.evaluate()
evaluator_result, _, _ = evaluator.evaluate()
except BaseException as error:
if expected_value == ThrowException:
return StatusType.SUCCESS, "Evaluator threw exception but it was expected"
Expand Down

0 comments on commit 7ee7c1c

Please sign in to comment.