Initial version with text-only output
This commit is contained in:
217
seasonal_clock/__init__.py
Normal file
217
seasonal_clock/__init__.py
Normal file
@@ -0,0 +1,217 @@
|
||||
"""Main module of the Seasonal Clock"""
|
||||
|
||||
__version__ = '0.1.0'
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Tuple, Union
|
||||
|
||||
from astral import Depression, LocationInfo, SunDirection, moon, sun
|
||||
from pytz import UTC
|
||||
|
||||
from .config import load_config
|
||||
from .hours import seasonal_hours
|
||||
|
||||
|
||||
def get_moon_phase_text(moon_phase: float) -> str:
|
||||
"""Get the name of the Moon phase"""
|
||||
|
||||
name = 'Dark Moon'
|
||||
|
||||
if moon_phase < 0.05:
|
||||
name = 'New Moon'
|
||||
elif moon_phase < 6.95:
|
||||
name = 'Waxing Crescent Moon'
|
||||
elif moon_phase < 7.05:
|
||||
name = 'Waxing Half Moon'
|
||||
elif moon_phase < 13.95:
|
||||
name = 'Waxing Gibbous Moon'
|
||||
elif moon_phase < 14.05:
|
||||
name = 'Full Moon'
|
||||
elif moon_phase < 20.95:
|
||||
name = 'Waning Gibbous Moon'
|
||||
elif moon_phase < 21.05:
|
||||
name = 'Waning Half Moon'
|
||||
elif moon_phase < 27.95:
|
||||
name = 'Waning Crescent Moon'
|
||||
|
||||
return name
|
||||
|
||||
|
||||
def collect_day_parts(
|
||||
observer: LocationInfo, date: datetime
|
||||
) -> List[Tuple[str, datetime, str, str]]:
|
||||
"""Collect timestamp for all parts of the day on date"""
|
||||
|
||||
day_parts = []
|
||||
|
||||
midnight = sun.midnight(observer, date=date)
|
||||
|
||||
if midnight.date() < date.date():
|
||||
midnight = sun.midnight(observer, date=date + timedelta(days=1))
|
||||
elif midnight.date() > date.date():
|
||||
midnight = sun.midnight(observer, date=date - timedelta(days=1))
|
||||
|
||||
day_parts.append(('midnight', midnight, 'Night', 'Midnight'))
|
||||
|
||||
try:
|
||||
morning_blue_start = sun.blue_hour(
|
||||
observer, date=date, direction=SunDirection.RISING
|
||||
)[0]
|
||||
except ValueError:
|
||||
# At certain times and latitudes there might be no blue hour
|
||||
pass
|
||||
else:
|
||||
day_parts.append(
|
||||
(
|
||||
'morning_blue_start',
|
||||
morning_blue_start,
|
||||
'Morning blue hour',
|
||||
'Morning golden hour',
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
golden_start, golden_end = sun.golden_hour(
|
||||
observer, date=date, direction=SunDirection.RISING
|
||||
)
|
||||
except ValueError:
|
||||
# At certain times and latitudes there might be no golden hour
|
||||
pass
|
||||
else:
|
||||
day_parts.append(
|
||||
('morning_golden_start', golden_start, 'Morning golden hour', 'Full Daytime')
|
||||
)
|
||||
day_parts.append(('morning_golden_end', golden_end, 'Morning', 'Noon'))
|
||||
|
||||
day_parts.append(('noon', sun.noon(observer, date=date), 'Afternoon', 'Noon'))
|
||||
|
||||
try:
|
||||
evening_golden_start = sun.golden_hour(observer, date=date, direction=SunDirection.SETTING)[0]
|
||||
except ValueError:
|
||||
# At certain times and latitudes there might be no golden hour
|
||||
pass
|
||||
else:
|
||||
day_parts.append(
|
||||
(
|
||||
'evening_golden_start',
|
||||
evening_golden_start,
|
||||
'Evening golden hour',
|
||||
'Evening blue hour',
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
blue_start, blue_end = sun.blue_hour(
|
||||
observer, date=date, direction=SunDirection.SETTING
|
||||
)
|
||||
except ValueError:
|
||||
# At certain times and latitudes there might be no blue hour
|
||||
pass
|
||||
else:
|
||||
day_parts.append(('evening_blue_start', blue_start, 'Evening blue hour', 'Night'))
|
||||
day_parts.append(('evening_blue_end', blue_end, 'Night', 'Midnight'))
|
||||
|
||||
day_parts.sort(key=lambda elem: elem[1])
|
||||
|
||||
return day_parts
|
||||
|
||||
|
||||
def get_rahukaalam(observer: LocationInfo, date: datetime) -> Tuple[bool, datetime]:
|
||||
"""Get the time of the next Rāhukāla or, if we are in the middle of one, the time of its end"""
|
||||
|
||||
yesterday = date - timedelta(days=1)
|
||||
tomorrow = date + timedelta(days=1)
|
||||
times: List[Tuple[datetime, datetime]] = []
|
||||
|
||||
for day in (yesterday, date, tomorrow):
|
||||
try:
|
||||
daytime_rahukaalam = sun.rahukaalam(observer, date=day, daytime=False)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
times.append(daytime_rahukaalam)
|
||||
|
||||
try:
|
||||
night_rahukaalam = sun.rahukaalam(observer, date=day, daytime=True)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
times.append(night_rahukaalam)
|
||||
|
||||
times.sort(key=lambda items: items[0])
|
||||
active: bool = False
|
||||
next_time: datetime = None
|
||||
|
||||
for start, end in times:
|
||||
if date < start:
|
||||
active = False
|
||||
next_time = start
|
||||
|
||||
break
|
||||
|
||||
if start < date < end:
|
||||
active = True
|
||||
next_time = end
|
||||
|
||||
break
|
||||
|
||||
if next_time is None:
|
||||
return None, None
|
||||
|
||||
return active, next_time
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""The Main Thing™"""
|
||||
|
||||
config = load_config()
|
||||
|
||||
location = LocationInfo(
|
||||
config['city'],
|
||||
config['country'],
|
||||
config['timezone'],
|
||||
config['latitude'],
|
||||
config['longitude'],
|
||||
)
|
||||
local_tz = location.tzinfo
|
||||
utc_now = datetime.utcnow().replace(tzinfo=UTC)
|
||||
local_now = utc_now.astimezone(local_tz)
|
||||
|
||||
moon_phase = moon.phase(local_now)
|
||||
|
||||
day_parts = collect_day_parts(location.observer, local_now)
|
||||
|
||||
final_name = 'Night'
|
||||
upcoming_name = None
|
||||
final_idx = -1
|
||||
|
||||
for idx, (_, time, name, next_name) in enumerate(day_parts):
|
||||
if utc_now > time:
|
||||
final_name = name
|
||||
upcoming_name = next_name
|
||||
final_idx = idx
|
||||
|
||||
next_time = day_parts[final_idx + 1][1]
|
||||
|
||||
if upcoming_name is None:
|
||||
upcoming_name = day_parts[0][2]
|
||||
|
||||
print(
|
||||
f'Now: {local_now.strftime("%Y-%m-%d %H:%M:%S")} (UTC {utc_now.strftime("%Y-%m-%d %H:%M:%S")})'
|
||||
)
|
||||
print(f'{final_name}, {next_time-local_now} until {upcoming_name}')
|
||||
|
||||
rahukaalam, next_rahukaalam = get_rahukaalam(location.observer, local_now)
|
||||
|
||||
if next_rahukaalam is not None:
|
||||
if rahukaalam:
|
||||
print(f'Rāhukāla (until {next_rahukaalam.strftime("%H:%M:%S")})')
|
||||
else:
|
||||
print(f'Next Rāhukāla at {next_rahukaalam.strftime("%H:%M:%S")}')
|
||||
|
||||
print(get_moon_phase_text(moon_phase))
|
||||
|
||||
hour_num = utc_now.hour
|
||||
hour_short = seasonal_hours[utc_now.hour].short
|
||||
hour_long = seasonal_hours[utc_now.hour].long
|
||||
print(f'{hour_long} ({hour_short}, {hour_num})')
|
45
seasonal_clock/config.py
Normal file
45
seasonal_clock/config.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Configuration handling for the Seasonal Clock"""
|
||||
|
||||
|
||||
import os
|
||||
from typing import Dict, Union
|
||||
|
||||
import toml
|
||||
from xdg import xdg_config_home
|
||||
|
||||
from .geo import get_country_city, get_lat_long, get_timezone
|
||||
|
||||
|
||||
def load_config() -> Dict[str, Union[str, float]]:
|
||||
"""Load the configuration from $XDG_CONFIG_HOME/seasonal-clock.toml"""
|
||||
|
||||
config_dir = xdg_config_home()
|
||||
config_filename = os.path.join(config_dir, 'seasonal-clock.toml')
|
||||
|
||||
if os.path.exists(config_filename):
|
||||
loaded_config = toml.load(config_filename) or {}
|
||||
config: Dict[str, Union[float, str]] = loaded_config.get('seasonal-clock')
|
||||
else:
|
||||
config = {}
|
||||
|
||||
if (
|
||||
'city' not in config
|
||||
and 'country' not in config
|
||||
and 'latitude' not in config
|
||||
and 'longitude' not in config
|
||||
):
|
||||
raise ValueError('No location configured')
|
||||
|
||||
if not config.get('longitude') or not config.get('latitude'):
|
||||
config['latitude'], config['longitude'] = get_lat_long(
|
||||
config['country'], config['city']
|
||||
)
|
||||
elif not config.get('country') or not config.get('city'):
|
||||
config['country'], config['city'] = get_country_city(
|
||||
config['latitude'], config['longitude']
|
||||
)
|
||||
|
||||
if not config.get('timezone'):
|
||||
config['timezone'] = get_timezone(config['latitude'], config['longitude'])
|
||||
|
||||
return config
|
45
seasonal_clock/geo.py
Normal file
45
seasonal_clock/geo.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Geolocation helpers for the Seasonal Clock"""
|
||||
|
||||
from typing import Tuple
|
||||
|
||||
from geopy.geocoders import Nominatim
|
||||
from tzwhere import tzwhere
|
||||
|
||||
from . import __version__
|
||||
|
||||
|
||||
def get_geolocator() -> Nominatim:
|
||||
"""Get the geolocator service"""
|
||||
|
||||
return Nominatim(user_agent=f'gpolonkai-seasonal-clock/{__version__}')
|
||||
|
||||
|
||||
def get_lat_long(country: str, city: str) -> Tuple[float, float]:
|
||||
"""Get the latitude and longitude based on a country and a city name
|
||||
|
||||
This implicitly communicates with OpenStreetMap’s API and thus needs a working Internet
|
||||
connection."""
|
||||
|
||||
locator = get_geolocator()
|
||||
geolocation = locator.geocode(f'{city}, {country}', addressdetails=True)
|
||||
|
||||
return geolocation.latitude, geolocation.longitude
|
||||
|
||||
|
||||
def get_country_city(latitude: float, longitude: float) -> Tuple[str, str]:
|
||||
"""Get the country and city names based on the coordinates
|
||||
|
||||
This implicitly communicates with OpenStreetMap’s API and thus needs a working Internet
|
||||
connection."""
|
||||
locator = get_geolocator()
|
||||
geolocation = locator.reverse(f'{latitude}, {longitude}', addressdetails=True)
|
||||
|
||||
return geolocation.raw['address']['country'], geolocation.raw['address']['city']
|
||||
|
||||
|
||||
def get_timezone(latitude: float, longitude: float) -> str:
|
||||
"""Get the time zone based on the coordinates"""
|
||||
|
||||
tzlocator = tzwhere.tzwhere()
|
||||
|
||||
return tzlocator.tzNameAt(latitude, longitude)
|
63
seasonal_clock/hours.py
Normal file
63
seasonal_clock/hours.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""Hour names for the Seasonal Clock"""
|
||||
|
||||
|
||||
class HourProxy:
|
||||
"""Proxy class to get short and long hour names"""
|
||||
|
||||
HOUR_NAMES = (
|
||||
('candle', 'candle hour'),
|
||||
('ice', 'hour of ice'),
|
||||
('comet', 'hour of the comet'),
|
||||
('thimble', 'hour of the thimble'),
|
||||
('root', 'hour of roots'),
|
||||
('mist', 'hour of mist'),
|
||||
('sprout', 'sprout hour'),
|
||||
('rainbow', 'rainbow hour'),
|
||||
('worm', 'worm hour'),
|
||||
('bud', 'bud hour'),
|
||||
('blossom', 'blossom hour'),
|
||||
('ladybug', 'ladybug hour'),
|
||||
('geese', 'hour of geese'),
|
||||
('dust', 'hour of dust'),
|
||||
('peach', 'hour of peach'),
|
||||
('fog', 'hour of fog'),
|
||||
('acorn', 'hour of acorn'),
|
||||
('gourd', 'hour of gourd'),
|
||||
('soup', 'soup hour'),
|
||||
('crow', 'crow hour'),
|
||||
('mushroom', 'mushroom hour'),
|
||||
('thunder', 'thunder hour'),
|
||||
('frost', 'frost hour'),
|
||||
('lantern', 'lantern hour'),
|
||||
)
|
||||
|
||||
def __init__(self, hour: int) -> None:
|
||||
self.hour = hour
|
||||
|
||||
@property
|
||||
def short(self) -> str:
|
||||
"""The short name of the hour"""
|
||||
|
||||
return self.HOUR_NAMES[self.hour][0]
|
||||
|
||||
@property
|
||||
def long(self) -> str:
|
||||
"""The long name of the hour"""
|
||||
|
||||
return self.HOUR_NAMES[self.hour][1]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.short
|
||||
|
||||
|
||||
class SeasonalHours: # pylint: disable=too-few-public-methods
|
||||
"""Class to access the hour names"""
|
||||
|
||||
def __getitem__(self, hour: int) -> HourProxy:
|
||||
if not 0 <= hour <= 23:
|
||||
raise ValueError(f'Invalid hour {hour}')
|
||||
|
||||
return HourProxy(hour)
|
||||
|
||||
|
||||
seasonal_hours = SeasonalHours()
|
Reference in New Issue
Block a user