Refactor geocoding methods in commands to utilize shared utility functions for improved consistency and efficiency. Introduce geocode_zipcode_sync and geocode_city_sync for streamlined location retrieval, enhancing error handling and caching mechanisms. Update AQI, solar forecast, and weather commands to leverage these new methods, reducing code duplication and improving maintainability.

This commit is contained in:
agessaman
2025-11-26 15:29:37 -08:00
parent 92f063b4bc
commit 6de424f894
4 changed files with 617 additions and 376 deletions
+106 -157
View File
@@ -10,7 +10,7 @@ import requests_cache
from retry_requests import retry
from datetime import datetime
from geopy.geocoders import Nominatim
from ..utils import rate_limited_nominatim_geocode_sync, rate_limited_nominatim_reverse_sync, get_nominatim_geocoder
from ..utils import rate_limited_nominatim_geocode_sync, rate_limited_nominatim_reverse_sync, get_nominatim_geocoder, abbreviate_location, geocode_zipcode_sync, geocode_city_sync
from .base_command import BaseCommand
from ..models import MeshMessage
@@ -352,25 +352,10 @@ class AqiCommand(BaseCommand):
except ValueError:
return f"Invalid coordinates format: {location}. Use format: lat,lon (e.g., 47.6,-122.3)"
elif location_type == "zipcode":
# Handle ZIP code geocoding using structured Nominatim queries
# Handle ZIP code geocoding with AQI-specific structured queries
try:
# Use the original ZIP code directly
zip_code = location.strip()
# Use structured query approach for better ZIP code handling
# Try different structured approaches based on Nominatim documentation
structured_queries = [
# Direct postalcode search
{"postalcode": zip_code, "country": "US"},
# Postalcode with state
{"postalcode": zip_code, "state": self.default_state, "country": "US"},
# Postalcode with country code
{"postalcode": zip_code, "countrycode": "US"},
# Fallback to text-based search
f"{zip_code} USA",
f"{zip_code} United States"
]
# Check for known problematic ZIP codes that need specific mapping
zip_code_mappings = {
'98013': 'Vashon, WA, USA',
@@ -392,17 +377,20 @@ class AqiCommand(BaseCommand):
except Exception as e:
self.logger.debug(f"Mapping failed for ZIP {zip_code}: {e}")
# If no mapping or mapping failed, try structured queries
# If no mapping, try structured queries (AQI-specific feature for better ZIP handling)
if not location_result:
structured_queries = [
# Direct postalcode search
{"postalcode": zip_code, "country": "US"},
# Postalcode with state
{"postalcode": zip_code, "state": self.default_state, "country": "US"},
# Postalcode with country code
{"postalcode": zip_code, "countrycode": "US"},
]
for query in structured_queries:
try:
if isinstance(query, dict):
# Use structured query
result = rate_limited_nominatim_geocode_sync(self.bot, query, timeout=10)
else:
# Use text-based query
result = rate_limited_nominatim_geocode_sync(self.bot, query, timeout=10)
result = rate_limited_nominatim_geocode_sync(self.bot, query, timeout=10)
if result and result.address:
# Check if it's a US location
if 'united states' in result.address.lower() or 'usa' in result.address.lower():
@@ -420,22 +408,38 @@ class AqiCommand(BaseCommand):
self.logger.debug(f"Structured query failed for {query}: {e}")
continue
if location_result:
# If structured queries didn't work, use shared function as fallback
if not location_result:
lat, lon = geocode_zipcode_sync(self.bot, zip_code, timeout=10)
if lat and lon:
# Use the shared function result directly
pass
else:
lat, lon = None, None
else:
lat = location_result.latitude
lon = location_result.longitude
if lat and lon:
# Get detailed address info via reverse geocoding
try:
reverse_location = rate_limited_nominatim_reverse_sync(self.bot, f"{lat}, {lon}", timeout=10)
if reverse_location:
address_info = reverse_location.raw.get('address', {})
else:
# Get detailed address info via reverse geocoding (check cache first)
reverse_cache_key = f"reverse_{lat}_{lon}"
cached_address = self.db_manager.get_cached_json(reverse_cache_key, "geolocation")
if cached_address:
address_info = cached_address
else:
try:
reverse_location = rate_limited_nominatim_reverse_sync(self.bot, f"{lat}, {lon}", timeout=10)
if reverse_location:
address_info = reverse_location.raw.get('address', {})
# Cache the reverse geocoding result
self.db_manager.cache_json(reverse_cache_key, address_info, "geolocation", cache_hours=720)
else:
address_info = {}
except:
address_info = {}
except:
address_info = {}
# Validate that the found location makes sense for the ZIP code
# Check if the found location is in the expected state or region
if address_info:
found_state = address_info.get('state', '')
found_country = address_info.get('country', '')
@@ -486,7 +490,7 @@ class AqiCommand(BaseCommand):
state = address_info.get('state', '')
# For US cities, use the state; for international cities, use the country
if country == "United States" or country == "US":
if country == "United States" or country == "US" or country == "United States of America":
# US city - use the state
actual_state = state or self.default_state
# Convert full state name to abbreviation if needed
@@ -497,9 +501,9 @@ class AqiCommand(BaseCommand):
actual_state = (country or
address_info.get('province') or
self.default_state)
# Abbreviate "United States" to "US" to save characters
if actual_state == "United States":
actual_state = "US"
# Normalize "United States" variants to "USA" to save characters
if actual_state == "United States" or actual_state == "United States of America":
actual_state = "USA"
# Also check if the default state needs to be converted for comparison
default_state_full = self.default_state
@@ -518,7 +522,9 @@ class AqiCommand(BaseCommand):
location_prefix = ""
if location_type == "city" and address_info:
# Always try to include city name if there's space
city_display = f"{actual_city}, {actual_state}"
# Use abbreviate_location to shorten long location strings (e.g., "United States of America" -> "USA")
full_location = f"{actual_city}, {actual_state}"
city_display = abbreviate_location(full_location, max_length=30)
# Check if we have space for the city name
test_output = f"{city_display}: {aqi_data}"
@@ -529,7 +535,9 @@ class AqiCommand(BaseCommand):
states_different = (actual_state != self.default_state and
actual_state != default_state_full)
if states_different:
location_prefix = f"{actual_city}, {actual_state}: "
# Use abbreviated version for shorter display
city_display_short = abbreviate_location(full_location, max_length=20)
location_prefix = f"{city_display_short}: "
elif location_type == "zipcode":
# Add location info for ZIP codes to confirm geocoding accuracy
if address_info:
@@ -559,7 +567,7 @@ class AqiCommand(BaseCommand):
country = address_info.get('country', '')
state = address_info.get('state', '')
if country == "United States" or country == "US":
if country == "United States" or country == "US" or country == "United States of America":
# US city - use the state
actual_state = state or self.default_state
# Convert full state name to abbreviation if needed
@@ -570,11 +578,13 @@ class AqiCommand(BaseCommand):
actual_state = (country or
address_info.get('province') or
self.default_state)
# Abbreviate "United States" to "US" to save characters
if actual_state == "United States":
actual_state = "US"
# Normalize "United States" variants to "USA" to save characters
if actual_state == "United States" or actual_state == "United States of America":
actual_state = "USA"
city_display = f"{actual_city}, {actual_state}"
# Use abbreviate_location to shorten long location strings (e.g., "United States of America" -> "USA")
full_location = f"{actual_city}, {actual_state}"
city_display = abbreviate_location(full_location, max_length=30)
# Check if we have space for the city name
test_output = f"{city_display}: {aqi_data}"
@@ -585,7 +595,9 @@ class AqiCommand(BaseCommand):
states_different = (actual_state != self.default_state and
actual_state != default_state_full)
if states_different:
location_prefix = f"{actual_city}, {actual_state}: "
# Use abbreviated version for shorter display
city_display_short = abbreviate_location(full_location, max_length=20)
location_prefix = f"{city_display_short}: "
else:
# No address info available
location_prefix = f"{zip_code}: "
@@ -602,20 +614,6 @@ class AqiCommand(BaseCommand):
def city_to_lat_lon(self, city: str) -> tuple:
"""Convert city name to latitude and longitude using default state"""
try:
# Check cache first for default state query
cache_query = f"{city}, {self.default_state}, USA"
cached_lat, cached_lon = self.db_manager.get_cached_geocoding(cache_query)
if cached_lat is not None and cached_lon is not None:
self.logger.debug(f"Using cached geocoding for {city}")
# Still need to do reverse geocoding for address details
try:
reverse_location = rate_limited_nominatim_reverse_sync(self.bot, f"{cached_lat}, {cached_lon}", timeout=10)
if reverse_location:
return cached_lat, cached_lon, reverse_location.raw.get('address', {})
except:
pass
return cached_lat, cached_lon, {}
# Check if the input contains a comma (city, state/country format)
if ',' in city:
# Parse city, state/country format
@@ -624,121 +622,72 @@ class AqiCommand(BaseCommand):
city_name = city_parts[0]
state_or_country = city_parts[1]
# Check if it's a country (not a US state)
# AQI-specific: Check if it's a country (not a US state)
country_indicators = ['canada', 'mexico', 'uk', 'united kingdom', 'france', 'germany', 'italy', 'spain', 'australia', 'japan', 'china', 'india', 'brazil', 'uae', 'russia', 'korea', 'thailand', 'singapore', 'egypt', 'turkey', 'israel', 'south africa', 'kenya', 'nigeria', 'argentina', 'peru', 'chile', 'colombia', 'venezuela', 'cuba', 'jamaica', 'puerto rico', 'iceland', 'norway', 'sweden', 'denmark', 'finland', 'poland', 'czech republic', 'hungary', 'romania', 'bulgaria', 'croatia', 'serbia', 'greece', 'portugal', 'ireland', 'belgium', 'netherlands', 'switzerland', 'austria', 'monaco', 'andorra', 'san marino', 'vatican', 'luxembourg', 'malta', 'cyprus', 'albania', 'macedonia', 'montenegro', 'bosnia', 'slovenia', 'slovakia', 'lithuania', 'latvia', 'estonia', 'belarus', 'ukraine', 'moldova', 'georgia', 'armenia', 'azerbaijan', 'kazakhstan', 'uzbekistan', 'kyrgyzstan', 'tajikistan', 'turkmenistan', 'afghanistan', 'pakistan', 'bangladesh', 'sri lanka', 'nepal', 'bhutan', 'myanmar', 'laos', 'cambodia', 'vietnam', 'malaysia', 'indonesia', 'philippines', 'taiwan', 'north korea', 'mongolia']
is_country = state_or_country.lower() in country_indicators
if is_country:
# Handle international cities
# Handle international cities explicitly (AQI-specific feature)
location = rate_limited_nominatim_geocode_sync(self.bot, f"{city_name}, {state_or_country}", timeout=10)
if location:
# Cache the result
self.db_manager.cache_geocoding(f"{city_name}, {state_or_country}", location.latitude, location.longitude)
# Use reverse geocoding to get detailed address info
try:
reverse_location = rate_limited_nominatim_reverse_sync(self.bot, f"{location.latitude}, {location.longitude}", timeout=10)
if reverse_location:
return location.latitude, location.longitude, reverse_location.raw.get('address', {})
except:
pass
# Use reverse geocoding to get detailed address info (check cache first)
reverse_cache_key = f"reverse_{location.latitude}_{location.longitude}"
cached_address = self.db_manager.get_cached_json(reverse_cache_key, "geolocation")
if cached_address:
return location.latitude, location.longitude, cached_address
else:
try:
reverse_location = rate_limited_nominatim_reverse_sync(self.bot, f"{location.latitude}, {location.longitude}", timeout=10)
if reverse_location:
address_info = reverse_location.raw.get('address', {})
# Cache the reverse geocoding result
self.db_manager.cache_json(reverse_cache_key, address_info, "geolocation", cache_hours=720)
return location.latitude, location.longitude, address_info
except:
pass
return location.latitude, location.longitude, location.raw.get('address', {})
else:
# Handle US city, state format
location = rate_limited_nominatim_geocode_sync(self.bot, f"{city_name}, {state_or_country}, USA", timeout=10)
if location:
# Cache the result
self.db_manager.cache_geocoding(f"{city_name}, {state_or_country}, USA", location.latitude, location.longitude)
# Use reverse geocoding to get detailed address info
try:
reverse_location = rate_limited_nominatim_reverse_sync(self.bot, f"{location.latitude}, {location.longitude}", timeout=10)
if reverse_location:
return location.latitude, location.longitude, reverse_location.raw.get('address', {})
except:
pass
return location.latitude, location.longitude, location.raw.get('address', {})
# For common city names, try major cities first to avoid small towns
major_city_mappings = {
'albany': ['Albany, NY, USA', 'Albany, OR, USA', 'Albany, CA, USA'],
'portland': ['Portland, OR, USA', 'Portland, ME, USA'],
'boston': ['Boston, MA, USA'],
'paris': ['Paris, TX, USA', 'Paris, IL, USA', 'Paris, TN, USA'],
'springfield': ['Springfield, IL, USA', 'Springfield, MO, USA', 'Springfield, MA, USA'],
'franklin': ['Franklin, TN, USA', 'Franklin, MA, USA'],
'georgetown': ['Georgetown, TX, USA', 'Georgetown, SC, USA'],
'madison': ['Madison, WI, USA', 'Madison, AL, USA'],
'auburn': ['Auburn, AL, USA', 'Auburn, WA, USA'],
'troy': ['Troy, NY, USA', 'Troy, MI, USA'],
'clinton': ['Clinton, IA, USA', 'Clinton, MS, USA']
}
# Use shared geocode_city_sync function with address info
default_country = self.bot.config.get('Weather', 'default_country', fallback='US')
lat, lon, address_info = geocode_city_sync(
self.bot, city, default_state=self.default_state,
default_country=default_country,
include_address_info=True, timeout=10
)
# If it's a major city with multiple locations, try the major ones first
if city.lower() in major_city_mappings:
for major_city_query in major_city_mappings[city.lower()]:
location = rate_limited_nominatim_geocode_sync(self.bot, major_city_query, timeout=10)
if location:
# Cache the result
self.db_manager.cache_geocoding(major_city_query, location.latitude, location.longitude)
# Use reverse geocoding to get detailed address info
try:
reverse_location = rate_limited_nominatim_reverse_sync(self.bot, f"{location.latitude}, {location.longitude}", timeout=10)
if reverse_location:
return location.latitude, location.longitude, reverse_location.raw.get('address', {})
except:
pass
return location.latitude, location.longitude, location.raw.get('address', {})
if lat and lon:
return lat, lon, address_info or {}
# First try with default state
location = rate_limited_nominatim_geocode_sync(self.bot, f"{city}, {self.default_state}, USA", timeout=10)
if location:
# Cache the result
self.db_manager.cache_geocoding(f"{city}, {self.default_state}, USA", location.latitude, location.longitude)
# Use reverse geocoding to get detailed address info
try:
reverse_location = rate_limited_nominatim_reverse_sync(self.bot, f"{location.latitude}, {location.longitude}", timeout=10)
if reverse_location:
return location.latitude, location.longitude, reverse_location.raw.get('address', {})
except:
pass
return location.latitude, location.longitude, location.raw.get('address', {})
else:
# Try neighborhood-specific queries for major cities
neighborhood_queries = self.get_neighborhood_queries(city)
# AQI-specific fallback: Try neighborhood-specific queries for major cities
neighborhood_queries = self.get_neighborhood_queries(city)
if neighborhood_queries:
for query in neighborhood_queries:
location = rate_limited_nominatim_geocode_sync(self.bot, query, timeout=10)
if location:
# Cache the result
self.db_manager.cache_geocoding(query, location.latitude, location.longitude)
# Use reverse geocoding to get detailed address info
try:
reverse_location = rate_limited_nominatim_reverse_sync(self.bot, f"{location.latitude}, {location.longitude}", timeout=10)
if reverse_location:
return location.latitude, location.longitude, reverse_location.raw.get('address', {})
except:
pass
return location.latitude, location.longitude, location.raw.get('address', {})
# Try without state as final fallback
location = rate_limited_nominatim_geocode_sync(self.bot, f"{city}, USA", timeout=10)
if location:
# Cache the result
self.db_manager.cache_geocoding(f"{city}, USA", location.latitude, location.longitude)
# Use reverse geocoding to get detailed address info
try:
reverse_location = rate_limited_nominatim_reverse_sync(self.bot, f"{location.latitude}, {location.longitude}", timeout=10)
if reverse_location:
return location.latitude, location.longitude, reverse_location.raw.get('address', {})
except:
pass
return location.latitude, location.longitude, location.raw.get('address', {})
else:
return (None, None, None)
# Use reverse geocoding to get detailed address info (check cache first)
reverse_cache_key = f"reverse_{location.latitude}_{location.longitude}"
cached_address = self.db_manager.get_cached_json(reverse_cache_key, "geolocation")
if cached_address:
return location.latitude, location.longitude, cached_address
else:
try:
reverse_location = rate_limited_nominatim_reverse_sync(self.bot, f"{location.latitude}, {location.longitude}", timeout=10)
if reverse_location:
address_info = reverse_location.raw.get('address', {})
# Cache the reverse geocoding result
self.db_manager.cache_json(reverse_cache_key, address_info, "geolocation", cache_hours=720)
return location.latitude, location.longitude, address_info
except:
pass
return location.latitude, location.longitude, location.raw.get('address', {})
return (None, None, None)
except Exception as e:
self.logger.error(f"Error geocoding city {city}: {e}")
return (None, None, None)
+17 -90
View File
@@ -10,12 +10,12 @@ import time
import hashlib
import pytz
from geopy.geocoders import Nominatim
from ..utils import rate_limited_nominatim_geocode, rate_limited_nominatim_reverse, get_nominatim_geocoder
from ..utils import rate_limited_nominatim_reverse, get_nominatim_geocoder, geocode_zipcode, geocode_city
from datetime import datetime, timedelta, timezone
from typing import Optional, Tuple, Dict
from .base_command import BaseCommand
from ..models import MeshMessage
from ..utils import abbreviate_location, get_major_city_queries
from ..utils import abbreviate_location
class SolarforecastCommand(BaseCommand):
@@ -345,103 +345,30 @@ class SolarforecastCommand(BaseCommand):
return None, None
async def _zipcode_to_lat_lon(self, zipcode: str) -> Tuple[Optional[float], Optional[float]]:
"""Convert zipcode to lat/lon"""
import asyncio
"""Convert zipcode to lat/lon using shared geocoding function"""
try:
cache_query = f"{zipcode}, USA"
cached_lat, cached_lon = self.db_manager.get_cached_geocoding(cache_query)
if cached_lat and cached_lon:
return cached_lat, cached_lon
location = await rate_limited_nominatim_geocode(
self.bot, f"{zipcode}, USA", timeout=self.url_timeout
)
if location:
self.db_manager.cache_geocoding(cache_query, location.latitude, location.longitude)
return location.latitude, location.longitude
lat, lon = await geocode_zipcode(self.bot, zipcode, timeout=self.url_timeout)
return lat, lon
except Exception as e:
self.logger.error(f"Error geocoding zipcode {zipcode}: {e}")
return None, None
return None, None
async def _city_to_lat_lon(self, city: str) -> Tuple[Optional[float], Optional[float]]:
"""Convert city name to lat/lon"""
import asyncio
"""Convert city name to lat/lon using shared geocoding function"""
try:
# Run geocoding in executor to avoid blocking
loop = asyncio.get_event_loop()
# Parse state abbreviation if present (e.g., "new york, ny" -> "new york" and "NY")
city_clean = city.strip()
state_abbr = None
if ',' in city_clean:
parts = [p.strip() for p in city_clean.rsplit(',', 1)]
if len(parts) == 2 and len(parts[1]) <= 2:
# Likely a state abbreviation
city_clean = parts[0]
state_abbr = parts[1].upper()
# Handle major cities with multiple locations (prioritize major cities)
# Use shared utility function for consistency across commands
major_city_queries = get_major_city_queries(city_clean, state_abbr)
if major_city_queries:
# Try major city options first
for major_city_query in major_city_queries:
cached_lat, cached_lon = self.db_manager.get_cached_geocoding(major_city_query)
if cached_lat and cached_lon:
return cached_lat, cached_lon
location = await rate_limited_nominatim_geocode(
self.bot, major_city_query, timeout=self.url_timeout
)
if location:
self.db_manager.cache_geocoding(major_city_query, location.latitude, location.longitude)
return location.latitude, location.longitude
# If state abbreviation was parsed, use it
if state_abbr:
state_query = f"{city_clean}, {state_abbr}, USA"
cached_lat, cached_lon = self.db_manager.get_cached_geocoding(state_query)
if cached_lat and cached_lon:
return cached_lat, cached_lon
location = await rate_limited_nominatim_geocode(
self.bot, state_query, timeout=self.url_timeout
)
if location:
self.db_manager.cache_geocoding(state_query, location.latitude, location.longitude)
return location.latitude, location.longitude
# Try with default state first
cache_query = f"{city_clean}, {self.default_state}, USA"
cached_lat, cached_lon = self.db_manager.get_cached_geocoding(cache_query)
if cached_lat and cached_lon:
return cached_lat, cached_lon
location = await rate_limited_nominatim_geocode(
self.bot, cache_query, timeout=self.url_timeout
# Get defaults from config
default_country = self.bot.config.get('Weather', 'default_country', fallback='US')
lat, lon, _ = await geocode_city(
self.bot, city,
default_state=self.default_state,
default_country=default_country,
include_address_info=False, # Don't need address info, just coordinates
timeout=self.url_timeout
)
if location:
self.db_manager.cache_geocoding(cache_query, location.latitude, location.longitude)
return location.latitude, location.longitude
# Try without state
location = await rate_limited_nominatim_geocode(
self.bot, f"{city_clean}, USA", timeout=self.url_timeout
)
if location:
self.db_manager.cache_geocoding(f"{city_clean}, USA", location.latitude, location.longitude)
return location.latitude, location.longitude
# Try international
location = await rate_limited_nominatim_geocode(
self.bot, city_clean, timeout=self.url_timeout
)
if location:
self.db_manager.cache_geocoding(city_clean, location.latitude, location.longitude)
return location.latitude, location.longitude
return lat, lon
except Exception as e:
self.logger.error(f"Error geocoding city {city}: {e}")
return None, None
return None, None
async def _get_location_name(self, lat: float, lon: float, original_location: str,
location_type: str) -> str:
+28 -128
View File
@@ -10,7 +10,7 @@ import requests
import xml.dom.minidom
from datetime import datetime, timedelta
from geopy.geocoders import Nominatim
from ..utils import rate_limited_nominatim_geocode_sync, rate_limited_nominatim_reverse_sync, get_nominatim_geocoder
from ..utils import rate_limited_nominatim_geocode_sync, rate_limited_nominatim_reverse_sync, get_nominatim_geocoder, geocode_zipcode_sync, geocode_city_sync
import maidenhead as mh
from .base_command import BaseCommand
from ..models import MeshMessage
@@ -320,14 +320,8 @@ class WxCommand(BaseCommand):
def zipcode_to_lat_lon(self, zipcode: str) -> tuple:
"""Convert zipcode to latitude and longitude"""
try:
# Use rate-limited Nominatim to geocode the zipcode
location = rate_limited_nominatim_geocode_sync(
self.bot, f"{zipcode}, USA", timeout=10
)
if location:
return location.latitude, location.longitude
else:
return None, None
lat, lon = geocode_zipcode_sync(self.bot, zipcode, timeout=10)
return lat, lon
except Exception as e:
self.logger.error(f"Error geocoding zipcode {zipcode}: {e}")
return None, None
@@ -335,124 +329,18 @@ class WxCommand(BaseCommand):
def city_to_lat_lon(self, city: str) -> tuple:
"""Convert city name to latitude and longitude using default state"""
try:
# Check cache first for default state query
cache_query = f"{city}, {self.default_state}, USA"
cached_lat, cached_lon = self.db_manager.get_cached_geocoding(cache_query)
if cached_lat is not None and cached_lon is not None:
self.logger.debug(f"Using cached geocoding for {city}")
# Still need to do reverse geocoding for address details
try:
reverse_location = rate_limited_nominatim_reverse_sync(
self.bot, f"{cached_lat}, {cached_lon}", timeout=10
)
if reverse_location:
return cached_lat, cached_lon, reverse_location.raw.get('address', {})
except:
pass
return cached_lat, cached_lon, {}
# Check if the input contains a comma (city, state format)
if ',' in city:
# Parse city, state format
city_parts = [part.strip() for part in city.split(',')]
if len(city_parts) >= 2:
city_name = city_parts[0]
state = city_parts[1]
# Try the specific city, state combination first
location = rate_limited_nominatim_geocode_sync(
self.bot, f"{city_name}, {state}, USA", timeout=10
)
if location:
# Cache the result
self.db_manager.cache_geocoding(f"{city_name}, {state}, USA", location.latitude, location.longitude)
# Use reverse geocoding to get detailed address info
try:
reverse_location = rate_limited_nominatim_reverse_sync(
self.bot, f"{location.latitude}, {location.longitude}", timeout=10
)
if reverse_location:
return location.latitude, location.longitude, reverse_location.raw.get('address', {})
except:
pass
return location.latitude, location.longitude, location.raw.get('address', {})
# For common city names, try major cities first to avoid small towns
major_city_mappings = {
'albany': ['Albany, NY, USA', 'Albany, OR, USA', 'Albany, CA, USA'],
'portland': ['Portland, OR, USA', 'Portland, ME, USA'],
'boston': ['Boston, MA, USA'],
'paris': ['Paris, TX, USA', 'Paris, IL, USA', 'Paris, TN, USA'],
'springfield': ['Springfield, IL, USA', 'Springfield, MO, USA', 'Springfield, MA, USA'],
'franklin': ['Franklin, TN, USA', 'Franklin, MA, USA'],
'georgetown': ['Georgetown, TX, USA', 'Georgetown, SC, USA'],
'madison': ['Madison, WI, USA', 'Madison, AL, USA'],
'auburn': ['Auburn, AL, USA', 'Auburn, WA, USA'],
'troy': ['Troy, NY, USA', 'Troy, MI, USA'],
'clinton': ['Clinton, IA, USA', 'Clinton, MS, USA']
}
# If it's a major city with multiple locations, try the major ones first
if city.lower() in major_city_mappings:
for major_city_query in major_city_mappings[city.lower()]:
location = rate_limited_nominatim_geocode_sync(
self.bot, major_city_query, timeout=10
)
if location:
# Cache the result
self.db_manager.cache_geocoding(major_city_query, location.latitude, location.longitude)
# Use reverse geocoding to get detailed address info
try:
reverse_location = rate_limited_nominatim_reverse_sync(
self.bot, f"{location.latitude}, {location.longitude}", timeout=10
)
if reverse_location:
return location.latitude, location.longitude, reverse_location.raw.get('address', {})
except:
pass
return location.latitude, location.longitude, location.raw.get('address', {})
# First try with default state
location = rate_limited_nominatim_geocode_sync(
self.bot, f"{city}, {self.default_state}, USA", timeout=10
# Use shared geocode_city_sync function with address info
default_country = self.bot.config.get('Weather', 'default_country', fallback='US')
lat, lon, address_info = geocode_city_sync(
self.bot, city, default_state=self.default_state,
default_country=default_country,
include_address_info=True, timeout=10
)
if location:
# Cache the result
self.db_manager.cache_geocoding(f"{city}, {self.default_state}, USA", location.latitude, location.longitude)
# Use reverse geocoding to get detailed address info
try:
reverse_location = rate_limited_nominatim_reverse_sync(
self.bot, f"{location.latitude}, {location.longitude}", timeout=10
)
if reverse_location:
return location.latitude, location.longitude, reverse_location.raw.get('address', {})
except:
pass
return location.latitude, location.longitude, location.raw.get('address', {})
if lat and lon:
return lat, lon, address_info or {}
else:
# Try without state as fallback
location = rate_limited_nominatim_geocode_sync(
self.bot, f"{city}, USA", timeout=10
)
if location:
# Cache the result
self.db_manager.cache_geocoding(f"{city}, USA", location.latitude, location.longitude)
# Use reverse geocoding to get detailed address info
try:
reverse_location = rate_limited_nominatim_reverse_sync(
self.bot, f"{location.latitude}, {location.longitude}", timeout=10
)
if reverse_location:
return location.latitude, location.longitude, reverse_location.raw.get('address', {})
except:
pass
return location.latitude, location.longitude, location.raw.get('address', {})
else:
return None, None, None
return None, None, None
except Exception as e:
self.logger.error(f"Error geocoding city {city}: {e}")
return None, None, None
@@ -469,8 +357,12 @@ class WxCommand(BaseCommand):
Tuple of (weather_string_or_periods, points_data)
"""
try:
# Round coordinates to 4 decimal places to avoid API redirects
lat_rounded = round(lat, 4)
lon_rounded = round(lon, 4)
# Get weather data from NOAA
weather_api = f"https://api.weather.gov/points/{lat},{lon}"
weather_api = f"https://api.weather.gov/points/{lat_rounded},{lon_rounded}"
# Get the forecast URL
weather_data = requests.get(weather_api, timeout=self.url_timeout)
@@ -803,8 +695,12 @@ class WxCommand(BaseCommand):
Tuple of (hourly_periods_list, points_data)
"""
try:
# Round coordinates to 4 decimal places to avoid API redirects
lat_rounded = round(lat, 4)
lon_rounded = round(lon, 4)
# Get weather data from NOAA
weather_api = f"https://api.weather.gov/points/{lat},{lon}"
weather_api = f"https://api.weather.gov/points/{lat_rounded},{lon_rounded}"
# Get the forecast URL
weather_data = requests.get(weather_api, timeout=self.url_timeout)
@@ -1392,7 +1288,11 @@ class WxCommand(BaseCommand):
def get_weather_alerts_noaa(self, lat: float, lon: float) -> tuple:
"""Get weather alerts from NOAA"""
try:
alert_url = f"https://api.weather.gov/alerts/active.atom?point={lat},{lon}"
# Round coordinates to 4 decimal places to avoid API redirects
lat_rounded = round(lat, 4)
lon_rounded = round(lon, 4)
alert_url = f"https://api.weather.gov/alerts/active.atom?point={lat_rounded},{lon_rounded}"
alert_data = requests.get(alert_url, timeout=self.url_timeout)
if not alert_data.ok:
+466 -1
View File
@@ -6,7 +6,7 @@ Shared helper functions used across multiple modules
import re
import hashlib
from typing import Optional
from typing import Optional, Tuple, Dict
def abbreviate_location(location: str, max_length: int = 20) -> str:
@@ -28,6 +28,7 @@ def abbreviate_location(location: str, max_length: int = 20) -> str:
abbreviations = [
('Central Business District', 'CBD'),
('United States of America', 'USA'),
('Business District', 'BD'),
('British Columbia', 'BC'),
('United States', 'USA'),
@@ -84,6 +85,10 @@ def abbreviate_location(location: str, max_length: int = 20) -> str:
('Wyoming', 'WY')
]
# Sort by length (longest first) to ensure longer matches are checked before shorter ones
# This prevents "United States" from matching before "United States of America"
abbreviations.sort(key=lambda x: len(x[0]), reverse=True)
# Apply abbreviations in order
for full_term, abbrev in abbreviations:
if full_term in abbreviated:
@@ -498,3 +503,463 @@ def rate_limited_nominatim_reverse_sync(bot, coordinates: str, timeout: int = 10
bot.nominatim_rate_limiter.record_request()
return result
async def geocode_zipcode(bot, zipcode: str, default_country: str = None, timeout: int = 10) -> Tuple[Optional[float], Optional[float]]:
"""
Shared function to geocode a ZIP code to lat/lon coordinates.
Checks cache first, then makes rate-limited API call if needed.
Args:
bot: Bot instance (must have db_manager and nominatim_rate_limiter)
zipcode: ZIP code string
default_country: Default country code (e.g., "US"). If None, reads from bot.config
timeout: Request timeout in seconds
Returns:
Tuple of (latitude, longitude) or (None, None) if not found
"""
try:
# Get default country from config if not provided
if default_country is None:
default_country = bot.config.get('Weather', 'default_country', fallback='US')
# Check cache first
cache_query = f"{zipcode}, {default_country}"
cached_lat, cached_lon = bot.db_manager.get_cached_geocoding(cache_query)
if cached_lat is not None and cached_lon is not None:
return cached_lat, cached_lon
# Use rate-limited Nominatim to geocode the zipcode
location = await rate_limited_nominatim_geocode(bot, cache_query, timeout=timeout)
if location:
# Cache the result for future use
bot.db_manager.cache_geocoding(cache_query, location.latitude, location.longitude)
return location.latitude, location.longitude
else:
return None, None
except Exception as e:
bot.logger.error(f"Error geocoding zipcode {zipcode}: {e}")
return None, None
def geocode_zipcode_sync(bot, zipcode: str, default_country: str = None, timeout: int = 10) -> Tuple[Optional[float], Optional[float]]:
"""
Synchronous version of geocode_zipcode.
Args:
bot: Bot instance (must have db_manager and nominatim_rate_limiter)
zipcode: ZIP code string
default_country: Default country code (e.g., "US"). If None, reads from bot.config
timeout: Request timeout in seconds
Returns:
Tuple of (latitude, longitude) or (None, None) if not found
"""
try:
# Get default country from config if not provided
if default_country is None:
default_country = bot.config.get('Weather', 'default_country', fallback='US')
# Check cache first
cache_query = f"{zipcode}, {default_country}"
cached_lat, cached_lon = bot.db_manager.get_cached_geocoding(cache_query)
if cached_lat is not None and cached_lon is not None:
return cached_lat, cached_lon
# Use rate-limited Nominatim to geocode the zipcode
location = rate_limited_nominatim_geocode_sync(bot, cache_query, timeout=timeout)
if location:
# Cache the result for future use
bot.db_manager.cache_geocoding(cache_query, location.latitude, location.longitude)
return location.latitude, location.longitude
else:
return None, None
except Exception as e:
bot.logger.error(f"Error geocoding zipcode {zipcode}: {e}")
return None, None
async def geocode_city(bot, city: str, default_state: str = None,
default_country: str = None,
include_address_info: bool = False,
timeout: int = 10) -> Tuple[Optional[float], Optional[float], Optional[Dict]]:
"""
Shared function to geocode a city name to lat/lon coordinates.
Uses intelligent fallback logic with major city prioritization.
Args:
bot: Bot instance (must have db_manager and nominatim_rate_limiter)
city: City name (may include state/country, e.g., "Seattle, WA" or "Paris, France")
default_state: Default state abbreviation (e.g., "WA"). If None, reads from bot.config
default_country: Default country code (e.g., "US"). If None, reads from bot.config
include_address_info: If True, also return address info via reverse geocoding
timeout: Request timeout in seconds
Returns:
Tuple of (latitude, longitude, address_info_dict) or (None, None, None) if not found
address_info_dict is None if include_address_info is False
"""
try:
# Get defaults from config if not provided
if default_state is None:
default_state = bot.config.get('Weather', 'default_state', fallback='WA')
if default_country is None:
default_country = bot.config.get('Weather', 'default_country', fallback='US')
city_clean = city.strip()
state_abbr = None
# Parse city, state/country format if present
if ',' in city_clean:
parts = [p.strip() for p in city_clean.rsplit(',', 1)]
if len(parts) == 2:
city_clean = parts[0]
state_abbr = parts[1].upper() if len(parts[1]) <= 2 else parts[1]
# Handle major cities with multiple locations (prioritize major cities)
major_city_queries = get_major_city_queries(city_clean, state_abbr)
if major_city_queries:
# Try major city options first
for major_city_query in major_city_queries:
cached_lat, cached_lon = bot.db_manager.get_cached_geocoding(major_city_query)
if cached_lat and cached_lon:
lat, lon = cached_lat, cached_lon
else:
location = await rate_limited_nominatim_geocode(bot, major_city_query, timeout=timeout)
if location:
bot.db_manager.cache_geocoding(major_city_query, location.latitude, location.longitude)
lat, lon = location.latitude, location.longitude
else:
continue
# Get address info if requested
address_info = None
if include_address_info:
# Check cache for reverse geocoding result
reverse_cache_key = f"reverse_{lat}_{lon}"
cached_address = bot.db_manager.get_cached_json(reverse_cache_key, "geolocation")
if cached_address:
address_info = cached_address
else:
try:
reverse_location = await rate_limited_nominatim_reverse(bot, f"{lat}, {lon}", timeout=timeout)
if reverse_location:
address_info = reverse_location.raw.get('address', {})
# Cache the reverse geocoding result
bot.db_manager.cache_json(reverse_cache_key, address_info, "geolocation", cache_hours=720)
except:
address_info = {}
return lat, lon, address_info
# If state abbreviation was parsed, use it
if state_abbr:
state_query = f"{city_clean}, {state_abbr}, {default_country}"
cached_lat, cached_lon = bot.db_manager.get_cached_geocoding(state_query)
if cached_lat and cached_lon:
lat, lon = cached_lat, cached_lon
else:
location = await rate_limited_nominatim_geocode(bot, state_query, timeout=timeout)
if location:
bot.db_manager.cache_geocoding(state_query, location.latitude, location.longitude)
lat, lon = location.latitude, location.longitude
else:
lat, lon = None, None
if lat and lon:
address_info = None
if include_address_info:
# Check cache for reverse geocoding result
reverse_cache_key = f"reverse_{lat}_{lon}"
cached_address = bot.db_manager.get_cached_json(reverse_cache_key, "geolocation")
if cached_address:
address_info = cached_address
else:
try:
reverse_location = await rate_limited_nominatim_reverse(bot, f"{lat}, {lon}", timeout=timeout)
if reverse_location:
address_info = reverse_location.raw.get('address', {})
# Cache the reverse geocoding result
bot.db_manager.cache_json(reverse_cache_key, address_info, "geolocation", cache_hours=720)
except:
address_info = {}
return lat, lon, address_info
# Try with default state
cache_query = f"{city_clean}, {default_state}, {default_country}"
cached_lat, cached_lon = bot.db_manager.get_cached_geocoding(cache_query)
if cached_lat and cached_lon:
lat, lon = cached_lat, cached_lon
else:
location = await rate_limited_nominatim_geocode(bot, cache_query, timeout=timeout)
if location:
bot.db_manager.cache_geocoding(cache_query, location.latitude, location.longitude)
lat, lon = location.latitude, location.longitude
else:
lat, lon = None, None
if lat and lon:
address_info = None
if include_address_info:
# Check cache for reverse geocoding result
reverse_cache_key = f"reverse_{lat}_{lon}"
cached_address = bot.db_manager.get_cached_json(reverse_cache_key, "geolocation")
if cached_address:
address_info = cached_address
else:
try:
reverse_location = await rate_limited_nominatim_reverse(bot, f"{lat}, {lon}", timeout=timeout)
if reverse_location:
address_info = reverse_location.raw.get('address', {})
# Cache the reverse geocoding result
bot.db_manager.cache_json(reverse_cache_key, address_info, "geolocation", cache_hours=720)
except:
address_info = {}
return lat, lon, address_info
# Try without state
location = await rate_limited_nominatim_geocode(bot, f"{city_clean}, {default_country}", timeout=timeout)
if location:
bot.db_manager.cache_geocoding(f"{city_clean}, {default_country}", location.latitude, location.longitude)
lat, lon = location.latitude, location.longitude
address_info = None
if include_address_info:
# Check cache for reverse geocoding result
reverse_cache_key = f"reverse_{lat}_{lon}"
cached_address = bot.db_manager.get_cached_json(reverse_cache_key, "geolocation")
if cached_address:
address_info = cached_address
else:
try:
reverse_location = await rate_limited_nominatim_reverse(bot, f"{lat}, {lon}", timeout=timeout)
if reverse_location:
address_info = reverse_location.raw.get('address', {})
# Cache the reverse geocoding result
bot.db_manager.cache_json(reverse_cache_key, address_info, "geolocation", cache_hours=720)
except:
address_info = {}
return lat, lon, address_info
# Try international (no country suffix)
location = await rate_limited_nominatim_geocode(bot, city_clean, timeout=timeout)
if location:
bot.db_manager.cache_geocoding(city_clean, location.latitude, location.longitude)
lat, lon = location.latitude, location.longitude
address_info = None
if include_address_info:
# Check cache for reverse geocoding result
reverse_cache_key = f"reverse_{lat}_{lon}"
cached_address = bot.db_manager.get_cached_json(reverse_cache_key, "geolocation")
if cached_address:
address_info = cached_address
else:
try:
reverse_location = await rate_limited_nominatim_reverse(bot, f"{lat}, {lon}", timeout=timeout)
if reverse_location:
address_info = reverse_location.raw.get('address', {})
# Cache the reverse geocoding result
bot.db_manager.cache_json(reverse_cache_key, address_info, "geolocation", cache_hours=720)
except:
address_info = {}
return lat, lon, address_info
return None, None, None
except Exception as e:
bot.logger.error(f"Error geocoding city {city}: {e}")
return None, None, None
def geocode_city_sync(bot, city: str, default_state: str = None,
default_country: str = None,
include_address_info: bool = False,
timeout: int = 10) -> Tuple[Optional[float], Optional[float], Optional[Dict]]:
"""
Synchronous version of geocode_city.
Args:
bot: Bot instance (must have db_manager and nominatim_rate_limiter)
city: City name (may include state/country, e.g., "Seattle, WA" or "Paris, France")
default_state: Default state abbreviation (e.g., "WA"). If None, reads from bot.config
default_country: Default country code (e.g., "US"). If None, reads from bot.config
include_address_info: If True, also return address info via reverse geocoding
timeout: Request timeout in seconds
Returns:
Tuple of (latitude, longitude, address_info_dict) or (None, None, None) if not found
address_info_dict is None if include_address_info is False
"""
try:
# Get defaults from config if not provided
if default_state is None:
default_state = bot.config.get('Weather', 'default_state', fallback='WA')
if default_country is None:
default_country = bot.config.get('Weather', 'default_country', fallback='US')
city_clean = city.strip()
state_abbr = None
# Parse city, state/country format if present
if ',' in city_clean:
parts = [p.strip() for p in city_clean.rsplit(',', 1)]
if len(parts) == 2:
city_clean = parts[0]
state_abbr = parts[1].upper() if len(parts[1]) <= 2 else parts[1]
# Handle major cities with multiple locations (prioritize major cities)
major_city_queries = get_major_city_queries(city_clean, state_abbr)
if major_city_queries:
# Try major city options first
for major_city_query in major_city_queries:
cached_lat, cached_lon = bot.db_manager.get_cached_geocoding(major_city_query)
if cached_lat and cached_lon:
lat, lon = cached_lat, cached_lon
else:
location = rate_limited_nominatim_geocode_sync(bot, major_city_query, timeout=timeout)
if location:
bot.db_manager.cache_geocoding(major_city_query, location.latitude, location.longitude)
lat, lon = location.latitude, location.longitude
else:
continue
# Get address info if requested
address_info = None
if include_address_info:
# Check cache for reverse geocoding result
reverse_cache_key = f"reverse_{lat}_{lon}"
cached_address = bot.db_manager.get_cached_json(reverse_cache_key, "geolocation")
if cached_address:
address_info = cached_address
else:
try:
reverse_location = rate_limited_nominatim_reverse_sync(bot, f"{lat}, {lon}", timeout=timeout)
if reverse_location:
address_info = reverse_location.raw.get('address', {})
# Cache the reverse geocoding result
bot.db_manager.cache_json(reverse_cache_key, address_info, "geolocation", cache_hours=720)
except:
address_info = {}
return lat, lon, address_info
# If state abbreviation was parsed, use it
if state_abbr:
state_query = f"{city_clean}, {state_abbr}, {default_country}"
cached_lat, cached_lon = bot.db_manager.get_cached_geocoding(state_query)
if cached_lat and cached_lon:
lat, lon = cached_lat, cached_lon
else:
location = rate_limited_nominatim_geocode_sync(bot, state_query, timeout=timeout)
if location:
bot.db_manager.cache_geocoding(state_query, location.latitude, location.longitude)
lat, lon = location.latitude, location.longitude
else:
lat, lon = None, None
if lat and lon:
address_info = None
if include_address_info:
# Check cache for reverse geocoding result
reverse_cache_key = f"reverse_{lat}_{lon}"
cached_address = bot.db_manager.get_cached_json(reverse_cache_key, "geolocation")
if cached_address:
address_info = cached_address
else:
try:
reverse_location = rate_limited_nominatim_reverse_sync(bot, f"{lat}, {lon}", timeout=timeout)
if reverse_location:
address_info = reverse_location.raw.get('address', {})
# Cache the reverse geocoding result
bot.db_manager.cache_json(reverse_cache_key, address_info, "geolocation", cache_hours=720)
except:
address_info = {}
return lat, lon, address_info
# Try with default state
cache_query = f"{city_clean}, {default_state}, {default_country}"
cached_lat, cached_lon = bot.db_manager.get_cached_geocoding(cache_query)
if cached_lat and cached_lon:
lat, lon = cached_lat, cached_lon
else:
location = rate_limited_nominatim_geocode_sync(bot, cache_query, timeout=timeout)
if location:
bot.db_manager.cache_geocoding(cache_query, location.latitude, location.longitude)
lat, lon = location.latitude, location.longitude
else:
lat, lon = None, None
if lat and lon:
address_info = None
if include_address_info:
# Check cache for reverse geocoding result
reverse_cache_key = f"reverse_{lat}_{lon}"
cached_address = bot.db_manager.get_cached_json(reverse_cache_key, "geolocation")
if cached_address:
address_info = cached_address
else:
try:
reverse_location = rate_limited_nominatim_reverse_sync(bot, f"{lat}, {lon}", timeout=timeout)
if reverse_location:
address_info = reverse_location.raw.get('address', {})
# Cache the reverse geocoding result
bot.db_manager.cache_json(reverse_cache_key, address_info, "geolocation", cache_hours=720)
except:
address_info = {}
return lat, lon, address_info
# Try without state
location = rate_limited_nominatim_geocode_sync(bot, f"{city_clean}, {default_country}", timeout=timeout)
if location:
bot.db_manager.cache_geocoding(f"{city_clean}, {default_country}", location.latitude, location.longitude)
lat, lon = location.latitude, location.longitude
address_info = None
if include_address_info:
# Check cache for reverse geocoding result
reverse_cache_key = f"reverse_{lat}_{lon}"
cached_address = bot.db_manager.get_cached_json(reverse_cache_key, "geolocation")
if cached_address:
address_info = cached_address
else:
try:
reverse_location = rate_limited_nominatim_reverse_sync(bot, f"{lat}, {lon}", timeout=timeout)
if reverse_location:
address_info = reverse_location.raw.get('address', {})
# Cache the reverse geocoding result
bot.db_manager.cache_json(reverse_cache_key, address_info, "geolocation", cache_hours=720)
except:
address_info = {}
return lat, lon, address_info
# Try international (no country suffix)
location = rate_limited_nominatim_geocode_sync(bot, city_clean, timeout=timeout)
if location:
bot.db_manager.cache_geocoding(city_clean, location.latitude, location.longitude)
lat, lon = location.latitude, location.longitude
address_info = None
if include_address_info:
# Check cache for reverse geocoding result
reverse_cache_key = f"reverse_{lat}_{lon}"
cached_address = bot.db_manager.get_cached_json(reverse_cache_key, "geolocation")
if cached_address:
address_info = cached_address
else:
try:
reverse_location = rate_limited_nominatim_reverse_sync(bot, f"{lat}, {lon}", timeout=timeout)
if reverse_location:
address_info = reverse_location.raw.get('address', {})
# Cache the reverse geocoding result
bot.db_manager.cache_json(reverse_cache_key, address_info, "geolocation", cache_hours=720)
except:
address_info = {}
return lat, lon, address_info
return None, None, None
except Exception as e:
bot.logger.error(f"Error geocoding city {city}: {e}")
return None, None, None