Auto-update: Fri Jun 28 23:26:17 PDT 2024
This commit is contained in:
parent
0660455eea
commit
bc1924dd4b
4 changed files with 102 additions and 57 deletions
|
@ -12,7 +12,7 @@ from typing import List, Optional
|
||||||
import traceback
|
import traceback
|
||||||
import logging
|
import logging
|
||||||
from .logs import Logger
|
from .logs import Logger
|
||||||
from .classes import AutoResponder, IMAPConfig, SMTPConfig, EmailAccount, EmailContact, IncomingEmail, TimezoneTracker, Database, Geocoder
|
from .classes import AutoResponder, IMAPConfig, SMTPConfig, EmailAccount, EmailContact, IncomingEmail, Database, Geocoder
|
||||||
|
|
||||||
# from sijapi.config.config import load_config
|
# from sijapi.config.config import load_config
|
||||||
# cfg = load_config()
|
# cfg = load_config()
|
||||||
|
|
|
@ -3,6 +3,8 @@ from typing import List, Optional, Any, Tuple, Dict, Union, Tuple
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import yaml
|
||||||
|
import math
|
||||||
from timezonefinder import TimezoneFinder
|
from timezonefinder import TimezoneFinder
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import asyncpg
|
import asyncpg
|
||||||
|
@ -46,6 +48,7 @@ class Location(BaseModel):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class Geocoder:
|
class Geocoder:
|
||||||
def __init__(self, named_locs: Union[str, Path] = None, cache_file: Union[str, Path] = 'timezone_cache.json'):
|
def __init__(self, named_locs: Union[str, Path] = None, cache_file: Union[str, Path] = 'timezone_cache.json'):
|
||||||
self.tf = TimezoneFinder()
|
self.tf = TimezoneFinder()
|
||||||
|
@ -56,10 +59,52 @@ class Geocoder:
|
||||||
self.last_update: Optional[datetime] = None
|
self.last_update: Optional[datetime] = None
|
||||||
self.last_location: Optional[Tuple[float, float]] = None
|
self.last_location: Optional[Tuple[float, float]] = None
|
||||||
self.executor = ThreadPoolExecutor()
|
self.executor = ThreadPoolExecutor()
|
||||||
|
self.override_locations = self.load_override_locations()
|
||||||
|
|
||||||
|
def load_override_locations(self):
|
||||||
|
if self.named_locs and self.named_locs.exists():
|
||||||
|
with open(self.named_locs, 'r') as file:
|
||||||
|
return yaml.safe_load(file)
|
||||||
|
return []
|
||||||
|
|
||||||
|
def haversine(self, lat1, lon1, lat2, lon2):
|
||||||
|
R = 6371
|
||||||
|
|
||||||
|
lat1, lon1, lat2, lon2 = map(math.radians, [lat1, lon1, lat2, lon2])
|
||||||
|
dlat = lat2 - lat1
|
||||||
|
dlon = lon2 - lon1
|
||||||
|
|
||||||
|
a = math.sin(dlat/2)**2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon/2)**2
|
||||||
|
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1-a))
|
||||||
|
|
||||||
|
return R * c
|
||||||
|
|
||||||
|
def find_override_location(self, lat: float, lon: float) -> Optional[str]:
|
||||||
|
closest_location = None
|
||||||
|
closest_distance = float('inf')
|
||||||
|
|
||||||
|
for location in self.override_locations:
|
||||||
|
loc_name = location.get("name")
|
||||||
|
loc_lat = location.get("latitude")
|
||||||
|
loc_lon = location.get("longitude")
|
||||||
|
loc_radius = location.get("radius")
|
||||||
|
|
||||||
|
distance = self.haversine(lat, lon, loc_lat, loc_lon)
|
||||||
|
|
||||||
|
if distance <= loc_radius:
|
||||||
|
if distance < closest_distance:
|
||||||
|
closest_distance = distance
|
||||||
|
closest_location = loc_name
|
||||||
|
|
||||||
|
return closest_location
|
||||||
|
|
||||||
async def location(self, lat: float, lon: float):
|
async def location(self, lat: float, lon: float):
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
return await loop.run_in_executor(self.executor, rg.search, [(lat, lon)])
|
result = await loop.run_in_executor(self.executor, rg.search, [(lat, lon)])
|
||||||
|
override = self.find_override_location(lat, lon)
|
||||||
|
if override:
|
||||||
|
result[0]['override_name'] = override
|
||||||
|
return result
|
||||||
|
|
||||||
async def elevation(self, latitude: float, longitude: float, unit: str = "m") -> float:
|
async def elevation(self, latitude: float, longitude: float, unit: str = "m") -> float:
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
|
@ -107,12 +152,14 @@ class Geocoder:
|
||||||
|
|
||||||
coordinates = [(location.latitude, location.longitude) for location in processed_locations]
|
coordinates = [(location.latitude, location.longitude) for location in processed_locations]
|
||||||
|
|
||||||
geocode_results = await self.location(*zip(*coordinates))
|
geocode_results = await asyncio.gather(*[self.location(lat, lon) for lat, lon in coordinates])
|
||||||
elevations = await asyncio.gather(*[self.elevation(lat, lon) for lat, lon in coordinates])
|
elevations = await asyncio.gather(*[self.elevation(lat, lon) for lat, lon in coordinates])
|
||||||
timezones = await asyncio.gather(*[self.timezone(lat, lon) for lat, lon in coordinates])
|
timezones = await asyncio.gather(*[self.timezone(lat, lon) for lat, lon in coordinates])
|
||||||
|
|
||||||
geocoded_locations = []
|
geocoded_locations = []
|
||||||
for location, result, elevation, timezone in zip(processed_locations, geocode_results, elevations, timezones):
|
for location, result, elevation, timezone in zip(processed_locations, geocode_results, elevations, timezones):
|
||||||
|
result = result[0] # Unpack the first result
|
||||||
|
override_name = result.get('override_name')
|
||||||
geocoded_location = Location(
|
geocoded_location = Location(
|
||||||
latitude=location.latitude,
|
latitude=location.latitude,
|
||||||
longitude=location.longitude,
|
longitude=location.longitude,
|
||||||
|
@ -123,8 +170,8 @@ class Geocoder:
|
||||||
state=result.get("admin1"),
|
state=result.get("admin1"),
|
||||||
country=result.get("cc"),
|
country=result.get("cc"),
|
||||||
context=location.context or {},
|
context=location.context or {},
|
||||||
name=result.get("name"),
|
name=override_name or result.get("name"),
|
||||||
display_name=f"{result.get('name')}, {result.get('admin1')}, {result.get('cc')}",
|
display_name=f"{override_name or result.get('name')}, {result.get('admin1')}, {result.get('cc')}",
|
||||||
country_code=result.get("cc"),
|
country_code=result.get("cc"),
|
||||||
timezone=timezone
|
timezone=timezone
|
||||||
)
|
)
|
||||||
|
@ -177,43 +224,11 @@ class Geocoder:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def load_override_locations(self):
|
def round_coords(self, lat: float, lon: float, decimal_places: int = 2) -> Tuple[float, float]:
|
||||||
if self.named_locs and self.named_locs.exists():
|
return (round(lat, decimal_places), round(lon, decimal_places))
|
||||||
with open(self.named_locs, 'r') as file:
|
|
||||||
return yaml.safe_load(file)
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
def coords_equal(self, coord1: Tuple[float, float], coord2: Tuple[float, float], tolerance: float = 1e-5) -> bool:
|
||||||
def haversine(self, lat1, lon1, lat2, lon2):
|
return math.isclose(coord1[0], coord2[0], abs_tol=tolerance) and math.isclose(coord1[1], coord2[1], abs_tol=tolerance)
|
||||||
R = 6371 # Earth's radius in kilometers
|
|
||||||
|
|
||||||
lat1, lon1, lat2, lon2 = map(radians, [lat1, lon1, lat2, lon2])
|
|
||||||
dlat = lat2 - lat1
|
|
||||||
dlon = lon2 - lon1
|
|
||||||
|
|
||||||
a = sin(dlat/2)**2 + cos(lat1) * cos(lat2) * sin(dlon/2)**2
|
|
||||||
c = 2 * atan2(sqrt(a), sqrt(1-a))
|
|
||||||
|
|
||||||
return R * c
|
|
||||||
|
|
||||||
async def find_override_location(self, lat: float, lon: float) -> Optional[str]:
|
|
||||||
closest_location = None
|
|
||||||
closest_distance = float('inf')
|
|
||||||
|
|
||||||
for location in self.override_locations:
|
|
||||||
loc_name = location.get("name")
|
|
||||||
loc_lat = location.get("latitude")
|
|
||||||
loc_lon = location.get("longitude")
|
|
||||||
loc_radius = location.get("radius")
|
|
||||||
|
|
||||||
distance = self.haversine(lat, lon, loc_lat, loc_lon)
|
|
||||||
|
|
||||||
if distance <= loc_radius:
|
|
||||||
if distance < closest_distance:
|
|
||||||
closest_distance = distance
|
|
||||||
closest_location = loc_name
|
|
||||||
|
|
||||||
return closest_location
|
|
||||||
|
|
||||||
async def refresh_timezone(self, location: Union[Location, Tuple[float, float]], force: bool = False) -> str:
|
async def refresh_timezone(self, location: Union[Location, Tuple[float, float]], force: bool = False) -> str:
|
||||||
if isinstance(location, Location):
|
if isinstance(location, Location):
|
||||||
|
@ -221,16 +236,20 @@ class Geocoder:
|
||||||
else:
|
else:
|
||||||
lat, lon = location
|
lat, lon = location
|
||||||
|
|
||||||
|
rounded_location = self.round_coords(lat, lon)
|
||||||
current_time = datetime.now()
|
current_time = datetime.now()
|
||||||
|
|
||||||
if (force or
|
if (force or
|
||||||
not self.last_update or
|
not self.last_update or
|
||||||
current_time - self.last_update > timedelta(hours=1) or
|
current_time - self.last_update > timedelta(hours=1) or
|
||||||
self.last_location != (lat, lon)):
|
not self.coords_equal(rounded_location, self.round_coords(*self.last_location) if self.last_location else (None, None))):
|
||||||
|
|
||||||
new_timezone = await self.timezone(lat, lon)
|
new_timezone = await self.timezone(lat, lon)
|
||||||
self.last_timezone = new_timezone
|
self.last_timezone = new_timezone
|
||||||
self.last_update = current_time
|
self.last_update = current_time
|
||||||
self.last_location = (lat, lon)
|
self.last_location = (lat, lon) # Store the original, non-rounded coordinates
|
||||||
await self.tz_save()
|
await self.tz_save()
|
||||||
|
|
||||||
return self.last_timezone
|
return self.last_timezone
|
||||||
|
|
||||||
async def tz_save(self):
|
async def tz_save(self):
|
||||||
|
@ -261,6 +280,17 @@ class Geocoder:
|
||||||
await self.tz_cached()
|
await self.tz_cached()
|
||||||
return self.last_timezone
|
return self.last_timezone
|
||||||
|
|
||||||
|
|
||||||
|
async def tz_at(self, lat: float, lon: float) -> str:
|
||||||
|
"""
|
||||||
|
Get the timezone at a specific latitude and longitude without affecting the cache.
|
||||||
|
|
||||||
|
:param lat: Latitude
|
||||||
|
:param lon: Longitude
|
||||||
|
:return: Timezone string
|
||||||
|
"""
|
||||||
|
return await self.timezone(lat, lon)
|
||||||
|
|
||||||
def __del__(self):
|
def __del__(self):
|
||||||
self.executor.shutdown()
|
self.executor.shutdown()
|
||||||
|
|
||||||
|
|
|
@ -13,7 +13,7 @@ from zoneinfo import ZoneInfo
|
||||||
from dateutil.parser import parse as dateutil_parse
|
from dateutil.parser import parse as dateutil_parse
|
||||||
from typing import Optional, List, Union
|
from typing import Optional, List, Union
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from sijapi import L, DB, TZ, NAMED_LOCATIONS, DynamicTZ, GEO
|
from sijapi import L, DB, TZ, NAMED_LOCATIONS, GEO
|
||||||
from sijapi.classes import Location
|
from sijapi.classes import Location
|
||||||
from sijapi.utilities import haversine
|
from sijapi.utilities import haversine
|
||||||
|
|
||||||
|
@ -35,8 +35,8 @@ async def dt(
|
||||||
# Handle provided timezone
|
# Handle provided timezone
|
||||||
if tz is not None:
|
if tz is not None:
|
||||||
if tz == "local":
|
if tz == "local":
|
||||||
last_loc = await get_last_location(date_time)
|
last_loc = await get_timezone_without_timezone(date_time)
|
||||||
tz_str = DynamicTZ.find(last_loc.latitude, last_loc.longitude)
|
tz_str = GEO.tz(last_loc.latitude, last_loc.longitude)
|
||||||
try:
|
try:
|
||||||
tz = ZoneInfo(tz_str)
|
tz = ZoneInfo(tz_str)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
@ -50,7 +50,7 @@ async def dt(
|
||||||
L.ERR(f"Invalid timezone string '{tz}'. Error: {e}")
|
L.ERR(f"Invalid timezone string '{tz}'. Error: {e}")
|
||||||
raise ValueError(f"Invalid timezone string: {tz}")
|
raise ValueError(f"Invalid timezone string: {tz}")
|
||||||
elif isinstance(tz, ZoneInfo):
|
elif isinstance(tz, ZoneInfo):
|
||||||
pass # tz is already a ZoneInfo object
|
tz = tz # tz is already a ZoneInfo object
|
||||||
else:
|
else:
|
||||||
raise ValueError("tz must be 'local', a string, or a ZoneInfo object.")
|
raise ValueError("tz must be 'local', a string, or a ZoneInfo object.")
|
||||||
|
|
||||||
|
@ -64,18 +64,16 @@ async def dt(
|
||||||
|
|
||||||
# If no timezone provided, only fill in missing timezone info
|
# If no timezone provided, only fill in missing timezone info
|
||||||
elif date_time.tzinfo is None:
|
elif date_time.tzinfo is None:
|
||||||
last_loc = get_last_location(date_time)
|
tz_str = await get_timezone_without_timezone(date_time)
|
||||||
tz_str = DynamicTZ.find(last_loc.latitude, last_loc.longitude)
|
|
||||||
try:
|
try:
|
||||||
tz = ZoneInfo(tz_str)
|
tz = ZoneInfo(tz_str)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
L.WARN(f"Invalid timezone string '{tz_str}' from DynamicTZ. Falling back to UTC. Error: {e}")
|
L.WARN(f"Invalid timezone string '{tz_str}' from Geocoder. Falling back to UTC. Error: {e}")
|
||||||
tz = ZoneInfo('UTC')
|
tz = ZoneInfo('UTC')
|
||||||
|
|
||||||
date_time = date_time.replace(tzinfo=tz)
|
date_time = date_time.replace(tzinfo=tz)
|
||||||
L.DEBUG(f"Filled in missing timezone info: {tz}")
|
L.DEBUG(f"Filled in missing timezone info: {tz}")
|
||||||
|
|
||||||
# If datetime already has timezone and no new timezone provided, do nothing
|
|
||||||
else:
|
else:
|
||||||
L.DEBUG(f"Datetime already has timezone {date_time.tzinfo}. No changes made.")
|
L.DEBUG(f"Datetime already has timezone {date_time.tzinfo}. No changes made.")
|
||||||
|
|
||||||
|
@ -87,6 +85,24 @@ async def dt(
|
||||||
L.ERR(f"Unexpected error in dt: {e}")
|
L.ERR(f"Unexpected error in dt: {e}")
|
||||||
raise ValueError(f"Failed to localize datetime: {e}")
|
raise ValueError(f"Failed to localize datetime: {e}")
|
||||||
|
|
||||||
|
async def get_timezone_without_timezone(date_time):
|
||||||
|
# This is a bit convoluted because we're trying to solve the paradox of needing to know the location in order to determine the timezone, but needing the timezone to be certain we've got the right location if this datetime coincided with inter-timezone travel. Our imperfect solution is to use UTC for an initial location query to determine roughly where we were at the time, get that timezone, then check the location again using that timezone, and if this location is different from the one using UTC, get the timezone again usng it, otherwise use the one we already sourced using UTC.
|
||||||
|
|
||||||
|
# Step 1: Use UTC as an interim timezone to query location
|
||||||
|
interim_dt = date_time.replace(tzinfo=ZoneInfo("UTC"))
|
||||||
|
interim_loc = await fetch_last_location_before(interim_dt)
|
||||||
|
|
||||||
|
# Step 2: Get a preliminary timezone based on the interim location
|
||||||
|
interim_tz = await GEO.tz_current((interim_loc.latitude, interim_loc.longitude))
|
||||||
|
|
||||||
|
# Step 3: Apply this preliminary timezone and query location again
|
||||||
|
query_dt = date_time.replace(tzinfo=ZoneInfo(interim_tz))
|
||||||
|
query_loc = await fetch_last_location_before(query_dt)
|
||||||
|
|
||||||
|
# Step 4: Get the final timezone, reusing interim_tz if location hasn't changed
|
||||||
|
return interim_tz if query_loc == interim_loc else await GEO.tz_current(query_loc.latitude, query_loc.longitude)
|
||||||
|
|
||||||
|
|
||||||
async def get_last_location() -> Optional[Location]:
|
async def get_last_location() -> Optional[Location]:
|
||||||
query_datetime = datetime.now(TZ)
|
query_datetime = datetime.now(TZ)
|
||||||
L.DEBUG(f"Query_datetime: {query_datetime}")
|
L.DEBUG(f"Query_datetime: {query_datetime}")
|
||||||
|
@ -329,7 +345,7 @@ async def post_locate_endpoint(locations: Union[Location, List[Location]]):
|
||||||
# Prepare locations
|
# Prepare locations
|
||||||
for location in locations:
|
for location in locations:
|
||||||
if not location.datetime:
|
if not location.datetime:
|
||||||
tz = DynamicTZ.find(location.latitude, location.longitude)
|
tz = GEO.tz_current(location.latitude, location.longitude)
|
||||||
location.datetime = datetime.now(tz).isoformat()
|
location.datetime = datetime.now(tz).isoformat()
|
||||||
|
|
||||||
if not location.context:
|
if not location.context:
|
||||||
|
|
|
@ -30,11 +30,10 @@ from dateutil.parser import parse as dateutil_parse
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from fastapi import APIRouter, Query, HTTPException
|
from fastapi import APIRouter, Query, HTTPException
|
||||||
from sijapi import L, OBSIDIAN_VAULT_DIR, OBSIDIAN_RESOURCES_DIR, ARCHIVE_DIR, BASE_URL, OBSIDIAN_BANNER_SCENE, DEFAULT_11L_VOICE, DEFAULT_VOICE, TZ, DynamicTZ, GEO
|
from sijapi import L, OBSIDIAN_VAULT_DIR, OBSIDIAN_RESOURCES_DIR, ARCHIVE_DIR, BASE_URL, OBSIDIAN_BANNER_SCENE, DEFAULT_11L_VOICE, DEFAULT_VOICE, GEO
|
||||||
from sijapi.routers import cal, loc, tts, llm, time, sd, weather, asr
|
from sijapi.routers import cal, loc, tts, llm, time, sd, weather, asr
|
||||||
from sijapi.routers.loc import Location
|
|
||||||
from sijapi.utilities import assemble_journal_path, assemble_archive_path, convert_to_12_hour_format, sanitize_filename, convert_degrees_to_cardinal, HOURLY_COLUMNS_MAPPING
|
from sijapi.utilities import assemble_journal_path, assemble_archive_path, convert_to_12_hour_format, sanitize_filename, convert_degrees_to_cardinal, HOURLY_COLUMNS_MAPPING
|
||||||
|
from sijapi.classes import Location
|
||||||
|
|
||||||
note = APIRouter()
|
note = APIRouter()
|
||||||
|
|
||||||
|
@ -70,7 +69,7 @@ async def build_daily_note_endpoint(
|
||||||
date_str = dt_datetime.now().strftime("%Y-%m-%d")
|
date_str = dt_datetime.now().strftime("%Y-%m-%d")
|
||||||
if location:
|
if location:
|
||||||
lat, lon = map(float, location.split(','))
|
lat, lon = map(float, location.split(','))
|
||||||
tz = ZoneInfo(DynamicTZ.find(lat, lon))
|
tz = GEO.tz_at(lat, lon)
|
||||||
date_time = dateutil_parse(date_str).replace(tzinfo=tz)
|
date_time = dateutil_parse(date_str).replace(tzinfo=tz)
|
||||||
else:
|
else:
|
||||||
raise ValueError("Location is not provided or invalid.")
|
raise ValueError("Location is not provided or invalid.")
|
||||||
|
|
Loading…
Reference in a new issue