|
|
# -*- coding: utf-8 -*-
|
|
|
|
|
|
# Copyright 2009-2021, Simon Kennedy, sffjunkie+code@gmail.com
|
|
|
|
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
# you may not use this file except in compliance with the License.
|
|
|
# You may obtain a copy of the License at
|
|
|
#
|
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
|
#
|
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
|
# See the License for the specific language governing permissions and
|
|
|
# limitations under the License.
|
|
|
|
|
|
"""Calculations for the position of the sun and moon.
|
|
|
|
|
|
The :mod:`astral` package provides the means to calculate the following times of the sun
|
|
|
|
|
|
* dawn
|
|
|
* sunrise
|
|
|
* noon
|
|
|
* midnight
|
|
|
* sunset
|
|
|
* dusk
|
|
|
* daylight
|
|
|
* night
|
|
|
* twilight
|
|
|
* blue hour
|
|
|
* golden hour
|
|
|
* rahukaalam
|
|
|
* moon rise, set, azimuth and zenith
|
|
|
|
|
|
plus solar azimuth and elevation at a specific latitude/longitude.
|
|
|
It can also calculate the moon phase for a specific date.
|
|
|
|
|
|
The package also provides a self contained geocoder to turn a small set of
|
|
|
location names into timezone, latitude and longitude. The lookups
|
|
|
can be perfomed using the :func:`~astral.geocoder.lookup` function defined in
|
|
|
:mod:`astral.geocoder`
|
|
|
"""
|
|
|
|
|
|
import datetime
|
|
|
import re
|
|
|
from dataclasses import dataclass, field
|
|
|
from enum import Enum
|
|
|
from math import radians, tan
|
|
|
from typing import Optional, Tuple, Union
|
|
|
|
|
|
try:
|
|
|
import zoneinfo
|
|
|
except ImportError:
|
|
|
from backports import zoneinfo
|
|
|
|
|
|
|
|
|
__all__ = [
|
|
|
"Depression",
|
|
|
"SunDirection",
|
|
|
"Observer",
|
|
|
"LocationInfo",
|
|
|
"AstralBodyPosition",
|
|
|
"now",
|
|
|
"today",
|
|
|
"dms_to_float",
|
|
|
"refraction_at_zenith",
|
|
|
]
|
|
|
|
|
|
__version__ = "3.2"
|
|
|
__author__ = "Simon Kennedy <sffjunkie+code@gmail.com>"
|
|
|
|
|
|
|
|
|
TimePeriod = Tuple[datetime.datetime, datetime.datetime]
|
|
|
Elevation = Union[float, Tuple[float, float]]
|
|
|
Degrees = float
|
|
|
Radians = float
|
|
|
Minutes = float
|
|
|
|
|
|
|
|
|
def now(tz: Optional[datetime.tzinfo] = None) -> datetime.datetime:
|
|
|
"""Returns the current time in the specified time zone"""
|
|
|
now_utc = datetime.datetime.now(datetime.timezone.utc)
|
|
|
if tz is None:
|
|
|
return now_utc
|
|
|
|
|
|
return now_utc.astimezone(tz)
|
|
|
|
|
|
|
|
|
def today(tz: Optional[datetime.tzinfo] = None) -> datetime.date:
|
|
|
"""Returns the current date in the specified time zone"""
|
|
|
return now(tz).date()
|
|
|
|
|
|
|
|
|
def dms_to_float(
|
|
|
dms: Union[str, float, Elevation], limit: Optional[float] = None
|
|
|
) -> float:
|
|
|
"""Converts as string of the form `degrees°minutes'seconds"[N|S|E|W]`,
|
|
|
or a float encoded as a string, to a float
|
|
|
|
|
|
N and E return positive values
|
|
|
S and W return negative values
|
|
|
|
|
|
Args:
|
|
|
dms: string to convert
|
|
|
limit: Limit the value between ± `limit`
|
|
|
|
|
|
Returns:
|
|
|
The number of degrees as a float
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
res = float(dms) # type: ignore
|
|
|
except (ValueError, TypeError) as exc:
|
|
|
_dms_re = r"(?P<deg>\d{1,3})[°]((?P<min>\d{1,2})[′'])?((?P<sec>\d{1,2})[″\"])?(?P<dir>[NSEW])?" # noqa
|
|
|
dms_match = re.match(_dms_re, str(dms), flags=re.IGNORECASE)
|
|
|
if dms_match:
|
|
|
deg = dms_match.group("deg") or 0.0
|
|
|
min_ = dms_match.group("min") or 0.0
|
|
|
sec = dms_match.group("sec") or 0.0
|
|
|
dir_ = dms_match.group("dir") or "E"
|
|
|
|
|
|
res = float(deg)
|
|
|
if min_:
|
|
|
res += float(min_) / 60
|
|
|
if sec:
|
|
|
res += float(sec) / 3600
|
|
|
|
|
|
if dir_.upper() in ["S", "W"]:
|
|
|
res = -res
|
|
|
else:
|
|
|
raise ValueError(
|
|
|
"Unable to convert degrees/minutes/seconds to float"
|
|
|
) from exc
|
|
|
|
|
|
if limit is not None:
|
|
|
if res > limit:
|
|
|
res = limit
|
|
|
elif res < -limit:
|
|
|
res = -limit
|
|
|
|
|
|
return res
|
|
|
|
|
|
|
|
|
def hours_to_time(value: float) -> datetime.time:
|
|
|
"""Convert a floating point number of hours to a datetime.time"""
|
|
|
|
|
|
hour = int(value)
|
|
|
value -= hour
|
|
|
value *= 60
|
|
|
minute = int(value)
|
|
|
value -= minute
|
|
|
value *= 60
|
|
|
second = int(value)
|
|
|
value -= second
|
|
|
microsecond = int(value * 1000000)
|
|
|
|
|
|
return datetime.time(hour, minute, second, microsecond)
|
|
|
|
|
|
|
|
|
def time_to_hours(value: datetime.time) -> float:
|
|
|
"""Convert a datetime.time to a floating point number of hours"""
|
|
|
|
|
|
hours = 0.0
|
|
|
hours += value.hour
|
|
|
hours += value.minute / 60
|
|
|
hours += value.second / 3600
|
|
|
hours += value.microsecond / 1000000
|
|
|
|
|
|
return hours
|
|
|
|
|
|
|
|
|
def time_to_seconds(value: datetime.time) -> float:
|
|
|
"""Convert a datetime.time to a floating point number of seconds"""
|
|
|
|
|
|
hours = time_to_hours(value)
|
|
|
return hours * 3600
|
|
|
|
|
|
|
|
|
def refraction_at_zenith(zenith: float) -> float:
|
|
|
"""Calculate the degrees of refraction of the sun due to the sun's elevation."""
|
|
|
|
|
|
elevation = 90 - zenith
|
|
|
if elevation >= 85.0:
|
|
|
return 0
|
|
|
|
|
|
refraction_correction = 0.0
|
|
|
te = tan(radians(elevation))
|
|
|
if elevation > 5.0:
|
|
|
refraction_correction = (
|
|
|
58.1 / te - 0.07 / (te * te * te) + 0.000086 / (te * te * te * te * te)
|
|
|
)
|
|
|
elif elevation > -0.575:
|
|
|
step1 = -12.79 + elevation * 0.711
|
|
|
step2 = 103.4 + elevation * step1
|
|
|
step3 = -518.2 + elevation * step2
|
|
|
refraction_correction = 1735.0 + elevation * step3
|
|
|
else:
|
|
|
refraction_correction = -20.774 / te
|
|
|
|
|
|
refraction_correction = refraction_correction / 3600.0
|
|
|
|
|
|
return refraction_correction
|
|
|
|
|
|
|
|
|
class Depression(Enum):
|
|
|
"""The depression angle in degrees for the dawn/dusk calculations"""
|
|
|
|
|
|
CIVIL = 6
|
|
|
NAUTICAL = 12
|
|
|
ASTRONOMICAL = 18
|
|
|
|
|
|
|
|
|
class SunDirection(Enum):
|
|
|
"""Direction of the sun either RISING or SETTING"""
|
|
|
|
|
|
RISING = 1
|
|
|
SETTING = -1
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
class AstralBodyPosition:
|
|
|
"""The position of an astral body as seen from earth"""
|
|
|
|
|
|
right_ascension: Radians = field(default_factory=float)
|
|
|
declination: Radians = field(default_factory=float)
|
|
|
distance: Radians = field(default_factory=float)
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
class Observer:
|
|
|
"""Defines the location of an observer on Earth.
|
|
|
|
|
|
Latitude and longitude can be set either as a float or as a string.
|
|
|
For strings they must be of the form
|
|
|
|
|
|
degrees°minutes'seconds"[N|S|E|W] e.g. 51°31'N
|
|
|
|
|
|
`minutes’` & `seconds”` are optional.
|
|
|
|
|
|
Elevations are either
|
|
|
|
|
|
* A float that is the elevation in metres above a location, if the nearest
|
|
|
obscuring feature is the horizon
|
|
|
* or a tuple of the elevation in metres and the distance in metres to the
|
|
|
nearest obscuring feature.
|
|
|
|
|
|
Args:
|
|
|
latitude: Latitude - Northern latitudes should be positive
|
|
|
longitude: Longitude - Eastern longitudes should be positive
|
|
|
elevation: Elevation and/or distance to nearest obscuring feature
|
|
|
in metres above/below the location.
|
|
|
"""
|
|
|
|
|
|
latitude: Degrees = 51.4733
|
|
|
longitude: Degrees = -0.0008333
|
|
|
elevation: Elevation = 0.0
|
|
|
|
|
|
def __setattr__(self, name: str, value: Union[str, float, Elevation]):
|
|
|
if name == "latitude":
|
|
|
value = dms_to_float(value, 90.0)
|
|
|
elif name == "longitude":
|
|
|
value = dms_to_float(value, 180.0)
|
|
|
elif name == "elevation":
|
|
|
if isinstance(value, tuple):
|
|
|
value = (float(value[0]), float(value[1]))
|
|
|
else:
|
|
|
value = float(value)
|
|
|
super().__setattr__(name, value)
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
class LocationInfo:
|
|
|
"""Defines a location on Earth.
|
|
|
|
|
|
Latitude and longitude can be set either as a float or as a string.
|
|
|
For strings they must be of the form
|
|
|
|
|
|
degrees°minutes'seconds"[N|S|E|W] e.g. 51°31'N
|
|
|
|
|
|
`minutes’` & `seconds”` are optional.
|
|
|
|
|
|
Args:
|
|
|
name: Location name (can be any string)
|
|
|
region: Region location is in (can be any string)
|
|
|
timezone: The location's time zone (a list of time zone names can be
|
|
|
obtained from `zoneinfo.available_timezones`)
|
|
|
latitude: Latitude - Northern latitudes should be positive
|
|
|
longitude: Longitude - Eastern longitudes should be positive
|
|
|
"""
|
|
|
|
|
|
name: str = "Greenwich"
|
|
|
region: str = "England"
|
|
|
timezone: str = "Europe/London"
|
|
|
latitude: Degrees = 51.4733
|
|
|
longitude: Degrees = -0.0008333
|
|
|
|
|
|
def __setattr__(self, name: str, value: Union[Degrees, str]):
|
|
|
if name == "latitude":
|
|
|
value = dms_to_float(value, 90.0)
|
|
|
elif name == "longitude":
|
|
|
value = dms_to_float(value, 180.0)
|
|
|
super().__setattr__(name, value)
|
|
|
|
|
|
@property
|
|
|
def observer(self):
|
|
|
"""Return an Observer at this location"""
|
|
|
return Observer(self.latitude, self.longitude, 0.0)
|
|
|
|
|
|
@property
|
|
|
def tzinfo(self): # type: ignore
|
|
|
"""Return a zoneinfo.ZoneInfo for this location"""
|
|
|
return zoneinfo.ZoneInfo(self.timezone) # type: ignore
|
|
|
|
|
|
@property
|
|
|
def timezone_group(self):
|
|
|
"""Return the group a timezone is in"""
|
|
|
return self.timezone.split("/", maxsplit=1)[0]
|