Files
smart-speaker/app/features/weather.py

494 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Weather feature module.
Fetches weather data from Open-Meteo API.
"""
import requests
from datetime import datetime
from ..core.config import WEATHER_LAT, WEATHER_LON, WEATHER_CITY
_HTTP = requests.Session()
def get_wmo_description(code: int) -> str:
"""Decodes WMO weather code to Russian description."""
codes = {
0: "ясно",
1: "преимущественно ясно",
2: "переменная облачность",
3: "пасмурно",
45: "туман",
48: "изморозь",
51: "легкая морось",
53: "умеренная морось",
55: "плотная морось",
56: "ледяная морось",
57: "плотная ледяная морось",
61: "слабый дождь",
63: "умеренный дождь",
65: "сильный дождь",
66: "ледяной дождь",
67: "сильный ледяной дождь",
71: "слабый снег",
73: "снегопад",
75: "сильный снегопад",
77: "снежные зерна",
80: "слабый ливень",
81: "умеренный ливень",
82: "сильный ливень",
85: "слабый снегопад",
86: "сильный снегопад",
95: "гроза",
96: "гроза с градом",
99: "сильная гроза с градом"
}
return codes.get(code, "осадки")
def get_temperature_text(temp: int) -> str:
"""
Returns the correct Russian form for temperature degrees based on the number.
Handles proper Russian grammar cases (падежи) for temperature values.
"""
# Get the absolute value to handle negative temperatures
abs_temp = abs(temp)
# Get the last digit
last_digit = abs_temp % 10
# Get the last two digits to handle special cases like 11-14
last_two_digits = abs_temp % 100
# Special cases for numbers ending in 11-14 (e.g., 11, 12, 13, 14, 111, 112, etc.)
if 11 <= last_two_digits <= 14:
return f"{temp} градусов"
# Cases based on the last digit
if last_digit == 1:
return f"{temp} градус"
elif 2 <= last_digit <= 4:
return f"{temp} градуса"
else: # 5-9, 0
return f"{temp} градусов"
def normalize_city_name(city_name: str) -> str:
"""
Converts city names from various grammatical cases to the base form for geocoding.
Handles common Russian grammatical cases (падежи) for city names.
"""
# Convert to lowercase for comparison
lower_city = city_name.lower()
# Remove common Russian location descriptors that might be included by mistake
# For example, if someone says "в городе Волгоград", the city_name might be "городе волгоград"
# So we want to extract just "волгоград"
if 'городе' in lower_city:
# Extract the part after "городе"
parts = lower_city.split('городе')
if len(parts) > 1:
lower_city = parts[1].strip()
elif 'город' in lower_city:
# Extract the part after "город"
parts = lower_city.split('город')
if len(parts) > 1:
lower_city = parts[1].strip()
# Common endings for different cases in Russian
# Prepositional case endings (-е, -и, -у, etc.)
prepositional_endings = ['е', 'и', 'у', 'о', 'й']
genitive_endings = ['а', 'я', 'ов', 'ев', 'ин', 'ын']
instrumental_endings = ['ом', 'ем', 'ой', 'ей']
# If the city ends with a prepositional ending, try removing it to get the base form
if lower_city.endswith(tuple(prepositional_endings)):
# Try to remove the ending and see if we get a valid base form
base_form = lower_city
# Try removing 1-2 characters to get the base form
for i in range(2, 0, -1): # Try removing 2 chars, then 1 char
if len(base_form) > i:
potential_base = base_form[:-i]
# Check if the removed part is a common ending
if base_form[-i:] in ['ке', 'ме', 'не', 'ве', 'ге', 'де', 'те']:
base_form = potential_base
break
elif base_form[-1] in prepositional_endings:
base_form = base_form[:-1]
break
# Special handling for common patterns
if base_form.endswith('йорке'): # "нью-йорке" -> "нью-йорк"
base_form = base_form[:-1] + 'к'
elif base_form.endswith('ске'): # "москве" -> "москва", "париже" -> "париж"
# This is more complex, but for "москве" -> "москва", "париже" -> "париж"
# We'll handle the most common cases
if base_form == 'москве':
base_form = 'москва'
elif base_form == 'париже':
base_form = 'париж'
elif base_form == 'лондоне':
base_form = 'лондон'
elif base_form == 'берлине':
base_form = 'берлин'
elif base_form == 'токио': # токио stays токио
base_form = 'токио'
else:
# General rule: replace -е with -а or -ь
if base_form.endswith('ске'):
base_form = base_form[:-1] + 'а'
elif base_form.endswith('ие'):
base_form = base_form[:-2] + 'ия'
# Capitalize appropriately
if base_form != lower_city:
return base_form.capitalize()
# Dictionary mapping specific known variations
case_variations = {
"нью-йорке": "Нью-Йорк",
"нью-йорка": "Нью-Йорк",
"нью-йорком": "Нью-Йорк",
"москве": "Москва",
"москвы": "Москва",
"москвой": "Москва",
"москву": "Москва",
"лондоне": "Лондон",
"лондона": "Лондон",
"лондоном": "Лондон",
"париже": "Париж",
"парижа": "Париж",
"парижем": "Париж",
"берлине": "Берлин",
"берлина": "Берлин",
"берлином": "Берлин",
"пекине": "Пекин",
"пекина": "Пекин",
"пекином": "Пекин",
"роме": "Рим",
"рима": "Рим",
"римом": "Рим",
"мадриде": "Мадрид",
"мадрида": "Мадрид",
"мадридом": "Мадрид",
"сиднее": "Сидней",
"сиднея": "Сидней",
"сиднеем": "Сидней",
"вашингтоне": "Вашингтон",
"вашингтона": "Вашингтон",
"вашингтоном": "Вашингтон",
"лос-анджелесе": "Лос-Анджелес",
"лос-анджелеса": "Лос-Анджелес",
"лос-анджелесом": "Лос-Анджелес",
"сиэтле": "Сиэтл",
"сиэтла": "Сиэтл",
"сиэтлом": "Сиэтл",
"бостоне": "Бостон",
"бостона": "Бостон",
"бостоном": "Бостон",
"денвере": "Денвер",
"денвера": "Денвер",
"денвером": "Денвер",
"хьюстоне": "Хьюстон",
"хьюстона": "Хьюстон",
"хьюстоном": "Хьюстон",
"фениксе": "Феникс",
"феникса": "Феникс",
"фениксом": "Феникс",
"атланте": "Атланта",
"атланты": "Атланта",
"атлантой": "Атланта",
"портленде": "Портленд",
"портленда": "Портленд",
"портлендом": "Портленд",
"остине": "Остин",
"остина": "Остин",
"остином": "Остин",
"нэшвилле": "Нэшвилл",
"нэшвилла": "Нэшвилл",
"нэшвиллом": "Нэшвилл",
"сан-франциско": "Сан-Франциско",
"токио": "Токио",
"торонто": "Торонто",
"чикаго": "Чикаго",
"майами": "Майами",
}
return case_variations.get(lower_city, city_name)
def get_coordinates_by_city(city_name: str) -> tuple:
"""
Gets coordinates (lat, lon) for a given city name using Open-Meteo geocoding API.
Returns (lat, lon, city_display_name) or (None, None, None) if not found.
"""
# First try with the original name
try_names = [city_name]
# Add normalized version
normalized_city = normalize_city_name(city_name)
if normalized_city != city_name:
try_names.append(normalized_city)
# Also try with English version if it's a known translation
city_to_eng = {
"москва": "Moscow",
"санкт-петербург": "Saint Petersburg",
"новосибирск": "Novosibirsk",
"екатеринбург": "Yekaterinburg",
"казань": "Kazan",
"нижний новгород": "Nizhny Novgorod",
"челябинск": "Chelyabinsk",
"омск": "Omsk",
"самара": "Samara",
"ростов-на-дону": "Rostov-on-Don",
"уфа": "Ufa",
"красноярск": "Krasnoyarsk",
"владивосток": "Vladivostok",
"сочи": "Sochi",
"новокузнецк": "Novokuznetsk",
"ярославль": "Yaroslavl",
"владикавказ": "Vladikavkaz",
"магнитогорск": "Magnitogorsk",
"иркутск": "Irkutsk",
"хабаровск": "Khabarovsk",
"оренбург": "Orenburg",
"калининград": "Kaliningrad",
"пермь": "Perm",
"волгоград": "Volgograd",
"волгограде": "Volgograd",
"краснодар": "Krasnodar",
"саратов": "Saratov",
"тында": "Tynda",
"тольятти": "Tolyatti",
"барнаул": "Barnaul",
"улан-удэ": "Ulan-Ude",
"иваново": "Ivanovo",
"мурманск": "Murmansk",
"кузнецк": "Kuznetsk",
"архангельск": "Arkhangelsk",
"владимир": "Vladimir",
"калининград": "Kaliningrad",
"смоленск": "Smolensk",
"калука": "Kaluga",
"воронеж": "Voronezh",
"курск": "Kursk",
"астрахань": "Astrakhan",
"липецк": "Lipetsk",
"тамбов": "Tambov",
"курган": "Kurgan",
"пенза": "Penza",
"рязн": "Ryazan",
"орёл": "Oryol",
"якутск": "Yakutsk",
"владикавказ": "Vladikavkaz",
"магас": "Magas",
"нарьян-мар": "Naryan-Mar",
"ханты-мансийск": "Khanty-Mansiysk",
"анадырь": "Anadyr",
"салехард": "Salekhard",
"лондон": "London",
"нью-йорк": "New York",
"токио": "Tokyo",
"париж": "Paris",
"берлин": "Berlin",
"мадрид": "Madrid",
"рим": "Rome",
"милан": "Milan",
"венеция": "Venice",
"амстердам": "Amsterdam",
"прага": "Prague",
"будапешт": "Budapest",
"вена": "Vienna",
"варшава": "Warsaw",
"киев": "Kyiv",
"минск": "Minsk",
"ташкент": "Tashkent",
"алматы": "Almaty",
"астана": "Astana",
"баку": "Baku",
"ереван": "Yerevan",
"тбилиси": "Tbilisi",
"софия": "Sofia",
"белград": "Belgrade",
"любляна": "Ljubljana",
"загреб": "Zagreb",
"рекьявик": "Reykjavik",
"осло": "Oslo",
"стокгольм": "Stockholm",
"копенгаген": "Copenhagen",
"хельсинки": "Helsinki",
"дублин": "Dublin",
"эдинбург": "Edinburgh",
"манчестер": "Manchester",
"бirmingham": "Birmingham",
"ливерпуль": "Liverpool",
"глазго": "Glasgow",
"брюссель": "Brussels",
"цюрих": "Zurich",
"женева": "Geneva",
"осака": "Osaka",
"киото": "Kyoto",
"сингапур": "Singapore",
"бангкок": "Bangkok",
"пекин": "Beijing",
"шанхай": "Shanghai",
"гонконг": "Hong Kong",
"сеул": "Seoul",
"дели": "Delhi",
"мумбаи": "Mumbai",
"бомбей": "Mumbai",
}
eng_name = city_to_eng.get(city_name.lower())
if eng_name:
try_names.append(eng_name)
# Try each name in sequence
for name_to_try in try_names:
try:
# Use Open-Meteo's geocoding API
geocode_url = "https://geocoding-api.open-meteo.com/v1/search"
params = {
"name": name_to_try,
"count": 1,
"language": "ru",
"format": "json"
}
response = _HTTP.get(geocode_url, params=params, timeout=10)
response.raise_for_status()
data = response.json()
if "results" in data and len(data["results"]) > 0:
result = data["results"][0]
lat = result["latitude"]
lon = result["longitude"]
display_name = result.get("name", name_to_try) # Use the name from the API if available
return lat, lon, display_name
except Exception as e:
print(f"❌ Ошибка при поиске координат для города {name_to_try}: {e}")
continue # Try the next name
return None, None, None
def get_weather_report(requested_city: str = None) -> str:
"""
Fetches detailed weather report.
Structure:
1. Current temp and precipitation.
2. Today's min/max temp.
3. Next 4 hours forecast (temp + precipitation).
Args:
requested_city: Optional city name to get weather for. If None, uses default city.
"""
# Determine which city to use
if requested_city:
# Try to get coordinates for the requested city
lat, lon, city_display_name = get_coordinates_by_city(requested_city)
if lat is None or lon is None:
return f"Не удалось найти город {requested_city}. Проверьте название и попробуйте снова."
else:
# Use default city from config
if not all([WEATHER_LAT, WEATHER_LON, WEATHER_CITY]):
return "Настройки погоды не найдены. Проверьте конфигурацию."
lat = float(WEATHER_LAT)
lon = float(WEATHER_LON)
city_display_name = WEATHER_CITY
url = "https://api.open-meteo.com/v1/forecast"
params = {
"latitude": lat,
"longitude": lon,
"current": "temperature_2m,precipitation,weather_code",
"hourly": "temperature_2m,precipitation,weather_code",
"daily": "temperature_2m_max,temperature_2m_min",
"timezone": "auto",
"forecast_days": 2
}
try:
response = _HTTP.get(url, params=params, timeout=10)
response.raise_for_status()
data = response.json()
# --- 1. Current Weather ---
curr = data["current"]
temp_now = round(curr["temperature_2m"])
precip_now = curr["precipitation"]
code_now = curr["weather_code"]
desc_now = get_wmo_description(code_now)
report = f"Сейчас в городе {city_display_name} {get_temperature_text(temp_now)}, {desc_now}."
if precip_now > 0:
report += f" Выпало {precip_now} миллиметров осадков."
# --- 2. Today's Range ---
# daily arrays usually start from today [0]
daily = data["daily"]
t_max = round(daily["temperature_2m_max"][0])
t_min = round(daily["temperature_2m_min"][0])
report += f" Сегодня температура будет от {get_temperature_text(t_min)} до {get_temperature_text(t_max)}."
# --- 3. Forecast Next 4 Hours ---
hourly_temps = data["hourly"]["temperature_2m"]
hourly_precip = data["hourly"]["precipitation"]
hourly_codes = data["hourly"]["weather_code"]
hourly_times = data["hourly"].get("time", [])
# Start from the next hour based on the API's current time (timezone-aware for the location).
current_time_iso = data.get("current", {}).get("time")
if current_time_iso and current_time_iso in hourly_times:
start_idx = hourly_times.index(current_time_iso) + 1
else:
current_hour = datetime.now().hour
start_idx = current_hour + 1
end_idx = min(start_idx + 4, len(hourly_temps))
next_temps = hourly_temps[start_idx:end_idx]
next_precip = hourly_precip[start_idx:end_idx]
next_codes = hourly_codes[start_idx:end_idx]
if next_temps:
report += " Прогноз на ближайшие 4 часа: "
# Simple approach for TTS:
avg_temp = round(sum(next_temps) / len(next_temps))
# Check if any precipitation is expected
will_precip = any(p > 0 for p in next_precip)
unique_codes = set(next_codes)
# Determine dominant weather description
if len(unique_codes) == 1:
weather_desc = get_wmo_description(list(unique_codes)[0])
else:
# Priority to precipitation codes
precip_codes = [c for c in unique_codes if c > 3] # >3 implies not clear/cloudy
if precip_codes:
weather_desc = get_wmo_description(max(precip_codes)) # Take the most severe
else:
weather_desc = "переменная облачность"
report += f"температура около {get_temperature_text(avg_temp)}, {weather_desc}."
if will_precip:
report += " Ожидаются осадки."
else:
report += " Без существенных осадков."
return report
except requests.exceptions.ConnectionError:
print(f"❌ Ошибка подключения к сервису погоды: невозможно подключиться к серверу")
return f"Не удалось подключиться к сервису погоды. Проверьте интернет-соединение."
except requests.exceptions.Timeout:
print(f"❌ Таймаут запроса к сервису погоды")
return f"Время ожидания ответа от сервиса погоды истекло."
except requests.exceptions.HTTPError as e:
print(f"❌ HTTP ошибка при получении погоды: {e}")
return f"Ошибка при получении данных о погоде: {e}"
except KeyError as e:
print(f"❌ Ошибка структуры данных погоды: {e}")
return f"Получены некорректные данные о погоде."
except Exception as e:
print(f"❌ Ошибка получения погоды: {e}")
return "Не удалось получить полные данные о погоде."