You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

318 lines
8.9 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# -*- 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]

Powered by TurnKey Linux.