From 6de424f89488c9aaf4bd60cb7ee9deb3a0889f5d Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 26 Nov 2025 15:29:37 -0800 Subject: [PATCH] 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. --- modules/commands/aqi_command.py | 263 +++++------- modules/commands/solarforecast_command.py | 107 +---- modules/commands/wx_command.py | 156 ++------ modules/utils.py | 467 +++++++++++++++++++++- 4 files changed, 617 insertions(+), 376 deletions(-) diff --git a/modules/commands/aqi_command.py b/modules/commands/aqi_command.py index 09d5988..625ea00 100644 --- a/modules/commands/aqi_command.py +++ b/modules/commands/aqi_command.py @@ -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) diff --git a/modules/commands/solarforecast_command.py b/modules/commands/solarforecast_command.py index a2ffe2f..8d9bd8a 100644 --- a/modules/commands/solarforecast_command.py +++ b/modules/commands/solarforecast_command.py @@ -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: diff --git a/modules/commands/wx_command.py b/modules/commands/wx_command.py index 6a841a7..c5739af 100644 --- a/modules/commands/wx_command.py +++ b/modules/commands/wx_command.py @@ -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: diff --git a/modules/utils.py b/modules/utils.py index a79c8bf..15b1c56 100644 --- a/modules/utils.py +++ b/modules/utils.py @@ -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