Backup automatico script del 2026-01-11 07:00
This commit is contained in:
@@ -4,8 +4,11 @@ import datetime
|
||||
import argparse
|
||||
import sys
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Optional, List
|
||||
from zoneinfo import ZoneInfo
|
||||
from dateutil import parser as date_parser # pyright: ignore[reportMissingModuleSource]
|
||||
from dateutil import parser as date_parser
|
||||
|
||||
# Setup logging
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
@@ -15,16 +18,77 @@ logger = logging.getLogger(__name__)
|
||||
HOME_LAT = 43.9356
|
||||
HOME_LON = 12.4296
|
||||
HOME_NAME = "🏠 Casa (Wide View ±12km)"
|
||||
TZ = "Europe/Rome"
|
||||
TZ = "Europe/Berlin"
|
||||
TZINFO = ZoneInfo(TZ)
|
||||
|
||||
# Offset ~12-15km
|
||||
# Offset ~12-15km per i 5 punti
|
||||
OFFSET_LAT = 0.12
|
||||
OFFSET_LON = 0.16
|
||||
|
||||
OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast"
|
||||
GEOCODING_URL = "https://geocoding-api.open-meteo.com/v1/search"
|
||||
HTTP_HEADERS = {"User-Agent": "loogle-bot-v10.4"}
|
||||
HTTP_HEADERS = {"User-Agent": "loogle-bot-v10.5"}
|
||||
|
||||
# --- TELEGRAM CONFIG ---
|
||||
TOKEN_FILE_HOME = os.path.expanduser("~/.telegram_dpc_bot_token")
|
||||
TOKEN_FILE_ETC = "/etc/telegram_dpc_bot_token"
|
||||
|
||||
def read_text_file(path: str) -> str:
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return f.read().strip()
|
||||
except FileNotFoundError:
|
||||
return ""
|
||||
except PermissionError:
|
||||
return ""
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading {path}: {e}")
|
||||
return ""
|
||||
|
||||
def load_bot_token() -> str:
|
||||
tok = os.environ.get("TELEGRAM_BOT_TOKEN", "").strip()
|
||||
if tok:
|
||||
return tok
|
||||
tok = read_text_file(TOKEN_FILE_HOME)
|
||||
if tok:
|
||||
return tok
|
||||
tok = read_text_file(TOKEN_FILE_ETC)
|
||||
return tok.strip() if tok else ""
|
||||
|
||||
def telegram_send_markdown(message_md: str, chat_ids: Optional[List[str]] = None) -> bool:
|
||||
"""Invia messaggio Markdown a Telegram. Returns True se almeno un invio è riuscito."""
|
||||
token = load_bot_token()
|
||||
if not token:
|
||||
logger.warning("Telegram token missing: message not sent.")
|
||||
return False
|
||||
|
||||
if chat_ids is None:
|
||||
return False # Se non specificato, non inviare
|
||||
|
||||
url = f"https://api.telegram.org/bot{token}/sendMessage"
|
||||
base_payload = {
|
||||
"text": message_md,
|
||||
"parse_mode": "Markdown",
|
||||
"disable_web_page_preview": True,
|
||||
}
|
||||
|
||||
sent_ok = False
|
||||
with requests.Session() as s:
|
||||
for chat_id in chat_ids:
|
||||
payload = dict(base_payload)
|
||||
payload["chat_id"] = chat_id
|
||||
try:
|
||||
resp = s.post(url, json=payload, timeout=15)
|
||||
if resp.status_code == 200:
|
||||
sent_ok = True
|
||||
else:
|
||||
logger.error("Telegram error chat_id=%s status=%s body=%s",
|
||||
chat_id, resp.status_code, resp.text[:500])
|
||||
time.sleep(0.25)
|
||||
except Exception as e:
|
||||
logger.exception("Telegram exception chat_id=%s err=%s", chat_id, e)
|
||||
|
||||
return sent_ok
|
||||
|
||||
def now_local() -> datetime.datetime:
|
||||
return datetime.datetime.now(TZINFO)
|
||||
@@ -44,15 +108,30 @@ def degrees_to_cardinal(d: int) -> str:
|
||||
return dirs[round(d / 45) % 8]
|
||||
except: return "N"
|
||||
|
||||
# --- HELPER SICUREZZA DATI ---
|
||||
def get_val(val, default=0.0):
|
||||
if val is None: return default
|
||||
return float(val)
|
||||
|
||||
def safe_get_list(hourly_data, key, length, default=None):
|
||||
if key in hourly_data and hourly_data[key] is not None:
|
||||
return hourly_data[key]
|
||||
return [default] * length
|
||||
|
||||
def get_icon_set(prec, snow, code, is_day, cloud, vis, temp, rain, gust, cape, cloud_type):
|
||||
sky = "☁️"
|
||||
try:
|
||||
# LOGICA NEVE (v10.5 Fix):
|
||||
# È neve se c'è accumulo OPPURE se il codice meteo dice neve (anche senza accumulo)
|
||||
is_snowing = snow > 0 or (code in [71, 73, 75, 77, 85, 86])
|
||||
|
||||
if cloud_type == 'F':
|
||||
sky = "🌫️"
|
||||
elif code in (95, 96, 99): sky = "⛈️" if prec > 0 else "🌩️"
|
||||
elif prec >= 0.1: sky = "🌨️" if snow > 0 else "🌧️"
|
||||
elif prec >= 0.1:
|
||||
sky = "🌨️" if is_snowing else "🌧️"
|
||||
else:
|
||||
# LOGICA PERCEZIONE UMANA
|
||||
# LOGICA PERCEZIONE UMANA (Nubi Alte vs Basse)
|
||||
if cloud_type == 'H':
|
||||
if cloud <= 40: sky = "☀️" if is_day else "🌙"
|
||||
elif cloud <= 80: sky = "🌤️" if is_day else "🌙"
|
||||
@@ -65,7 +144,8 @@ def get_icon_set(prec, snow, code, is_day, cloud, vis, temp, rain, gust, cape, c
|
||||
else: sky = "☁️"
|
||||
|
||||
sgx = "-"
|
||||
if snow > 0 or (code is not None and code in (71,73,75,77,85,86)): sgx = "☃️"
|
||||
# Simbolo laterale (Priorità agli eventi pericolosi)
|
||||
if is_snowing: sgx = "☃️"
|
||||
elif temp < 0 or (code is not None and code in (66,67)): sgx = "🧊"
|
||||
elif cape > 2000: sgx = "🌪️"
|
||||
elif cape > 1000: sgx = "⚡"
|
||||
@@ -92,15 +172,36 @@ def get_coordinates(city_name: str):
|
||||
logger.error(f"Geocoding error: {e}")
|
||||
return None
|
||||
|
||||
def choose_best_model(lat, lon, cc):
|
||||
if cc == 'JP': return "jma_msm", "JMA MSM"
|
||||
if cc in ['NO', 'SE', 'FI', 'DK', 'IS']: return "metno_nordic", "Yr.no"
|
||||
if cc in ['GB', 'IE']: return "ukmo_global", "UK MetOffice"
|
||||
if cc == 'IT' or cc == 'SM': return "meteofrance_arome_france_hd", "AROME HD"
|
||||
if cc in ['DE', 'AT', 'CH', 'LI', 'FR']: return "icon_d2", "ICON-D2"
|
||||
return "gfs_global", "NOAA GFS"
|
||||
def choose_best_model(lat, lon, cc, is_home=False):
|
||||
"""
|
||||
Sceglie il modello meteo.
|
||||
- Per Casa: usa AROME Seamless (ha snowfall)
|
||||
- Per altre località: usa best match di Open-Meteo (senza specificare models)
|
||||
"""
|
||||
if is_home:
|
||||
# Per Casa, usa AROME Seamless (ha snowfall e dati dettagliati)
|
||||
return "meteofrance_seamless", "AROME HD"
|
||||
else:
|
||||
# Per query worldwide, usa best match (Open-Meteo sceglie automaticamente)
|
||||
return None, "Best Match"
|
||||
|
||||
def get_forecast(lat, lon, model):
|
||||
def get_forecast(lat, lon, model=None, is_home=False, timezone=None, retry_after_60s=False):
|
||||
"""
|
||||
Recupera forecast. Se model è None, usa best match di Open-Meteo.
|
||||
Per Casa (is_home=True), usa AROME Seamless.
|
||||
|
||||
Args:
|
||||
retry_after_60s: Se True, attende 10 secondi prima di riprovare (per retry)
|
||||
"""
|
||||
# Usa timezone personalizzata se fornita, altrimenti default
|
||||
tz_to_use = timezone if timezone else TZ
|
||||
|
||||
# Se è un retry, attendi 10 secondi (ridotto da 60s per evitare timeout esterni)
|
||||
if retry_after_60s:
|
||||
logger.info("Attendo 10 secondi prima del retry...")
|
||||
time.sleep(10)
|
||||
|
||||
# Generiamo 5 punti: Centro, N, S, E, W
|
||||
lats = [lat, lat + OFFSET_LAT, lat - OFFSET_LAT, lat, lat]
|
||||
lons = [lon, lon, lon, lon + OFFSET_LON, lon - OFFSET_LON]
|
||||
|
||||
@@ -108,38 +209,132 @@ def get_forecast(lat, lon, model):
|
||||
lon_str = ",".join(map(str, lons))
|
||||
|
||||
params = {
|
||||
"latitude": lat_str, "longitude": lon_str, "timezone": TZ,
|
||||
"latitude": lat_str, "longitude": lon_str, "timezone": tz_to_use,
|
||||
"forecast_days": 3,
|
||||
"models": model,
|
||||
"wind_speed_unit": "kmh", "precipitation_unit": "mm",
|
||||
"hourly": "temperature_2m,apparent_temperature,relative_humidity_2m,cloud_cover,cloud_cover_low,cloud_cover_mid,cloud_cover_high,windspeed_10m,winddirection_10m,windgusts_10m,precipitation,rain,snowfall,weathercode,is_day,cape,visibility,uv_index"
|
||||
}
|
||||
|
||||
# Aggiungi models solo se specificato (per Casa usa AROME, per altre località best match)
|
||||
if model:
|
||||
params["models"] = model
|
||||
|
||||
# Nota: minutely_15 non è usato in meteo.py (solo per script di allerta)
|
||||
try:
|
||||
r = requests.get(OPEN_METEO_URL, params=params, headers=HTTP_HEADERS, timeout=25)
|
||||
if r.status_code != 200:
|
||||
logger.error(f"API Error {r.status_code}: {r.text}")
|
||||
return None
|
||||
return r.json()
|
||||
# Dettagli errore più specifici
|
||||
error_details = f"Status {r.status_code}"
|
||||
try:
|
||||
error_json = r.json()
|
||||
if "reason" in error_json:
|
||||
error_details += f": {error_json['reason']}"
|
||||
elif "error" in error_json:
|
||||
error_details += f": {error_json['error']}"
|
||||
else:
|
||||
error_details += f": {r.text[:200]}"
|
||||
except:
|
||||
error_details += f": {r.text[:200]}"
|
||||
logger.error(f"API Error {error_details}")
|
||||
return None, error_details # Restituisce anche i dettagli dell'errore
|
||||
response_data = r.json()
|
||||
# Open-Meteo per multiple locations (lat/lon separati da virgola) restituisce
|
||||
# direttamente un dict con "hourly", "daily", etc. che contiene liste di valori
|
||||
# per ogni location. Per semplicità, restituiamo il dict così com'è
|
||||
# e lo gestiamo nel codice chiamante
|
||||
return response_data, None
|
||||
except requests.exceptions.Timeout as e:
|
||||
error_details = f"Timeout dopo 25s: {str(e)}"
|
||||
logger.error(f"Request timeout: {error_details}")
|
||||
return None, error_details
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
error_details = f"Errore connessione: {str(e)}"
|
||||
logger.error(f"Connection error: {error_details}")
|
||||
return None, error_details
|
||||
except Exception as e:
|
||||
logger.error(f"Request error: {e}")
|
||||
return None
|
||||
error_details = f"Errore richiesta: {type(e).__name__}: {str(e)}"
|
||||
logger.error(f"Request error: {error_details}")
|
||||
return None, error_details
|
||||
|
||||
def safe_get_list(hourly_data, key, length, default=None):
|
||||
if key in hourly_data and hourly_data[key] is not None:
|
||||
return hourly_data[key]
|
||||
return [default] * length
|
||||
|
||||
def get_val(val, default=0.0):
|
||||
if val is None: return default
|
||||
return float(val)
|
||||
|
||||
def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT") -> str:
|
||||
model_id, model_name = choose_best_model(lat, lon, cc)
|
||||
def get_visibility_forecast(lat, lon):
|
||||
"""
|
||||
Recupera visibilità per località dove il modello principale non la fornisce.
|
||||
Prova prima ECMWF IFS, poi fallback a best match (GFS o ICON-D2).
|
||||
"""
|
||||
# Prova prima con ECMWF IFS
|
||||
params_ecmwf = {
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
"timezone": TZ,
|
||||
"forecast_days": 3,
|
||||
"models": "ecmwf_ifs04",
|
||||
"hourly": "visibility"
|
||||
}
|
||||
try:
|
||||
r = requests.get(OPEN_METEO_URL, params=params_ecmwf, headers=HTTP_HEADERS, timeout=15)
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
hourly = data.get("hourly", {})
|
||||
vis = hourly.get("visibility", [])
|
||||
# Verifica se ci sono valori validi (non tutti None)
|
||||
if vis and any(v is not None for v in vis):
|
||||
return vis
|
||||
except Exception as e:
|
||||
logger.debug(f"ECMWF IFS visibility request error: {e}")
|
||||
|
||||
# Fallback: usa best match (senza models) che seleziona automaticamente GFS o ICON-D2
|
||||
params_best = {
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
"timezone": TZ,
|
||||
"forecast_days": 3,
|
||||
"hourly": "visibility"
|
||||
}
|
||||
try:
|
||||
r = requests.get(OPEN_METEO_URL, params=params_best, headers=HTTP_HEADERS, timeout=15)
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
hourly = data.get("hourly", {})
|
||||
return hourly.get("visibility", [])
|
||||
except Exception as e:
|
||||
logger.error(f"Visibility request error: {e}")
|
||||
return None
|
||||
|
||||
def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT", timezone=None) -> str:
|
||||
# Determina se è Casa
|
||||
is_home = (abs(lat - HOME_LAT) < 0.01 and abs(lon - HOME_LON) < 0.01)
|
||||
|
||||
# Usa timezone personalizzata se fornita, altrimenti default
|
||||
tz_to_use = timezone if timezone else TZ
|
||||
|
||||
model_id, model_name = choose_best_model(lat, lon, cc, is_home=is_home)
|
||||
|
||||
# Tentativo 1: Richiesta iniziale
|
||||
data_list, error_details = get_forecast(lat, lon, model_id, is_home=is_home, timezone=tz_to_use, retry_after_60s=False)
|
||||
|
||||
# Se fallisce e siamo a Casa con AROME, prova retry dopo 10 secondi
|
||||
if not data_list and is_home and model_id == "meteofrance_seamless":
|
||||
logger.warning(f"Primo tentativo AROME fallito: {error_details}. Retry dopo 10 secondi...")
|
||||
data_list, error_details = get_forecast(lat, lon, model_id, is_home=is_home, timezone=tz_to_use, retry_after_60s=True)
|
||||
|
||||
# Se ancora fallisce e siamo a Casa, fallback a best match
|
||||
if not data_list and is_home:
|
||||
logger.warning(f"AROME fallito anche dopo retry: {error_details}. Fallback a best match...")
|
||||
data_list, error_details = get_forecast(lat, lon, None, is_home=False, timezone=tz_to_use, retry_after_60s=False)
|
||||
if data_list:
|
||||
model_name = "Best Match (fallback)"
|
||||
logger.info("Fallback a best match riuscito")
|
||||
|
||||
# Se ancora fallisce, restituisci errore dettagliato
|
||||
if not data_list:
|
||||
error_msg = f"❌ Errore API Meteo ({model_name})"
|
||||
if error_details:
|
||||
error_msg += f"\n\nDettagli: {error_details}"
|
||||
return error_msg
|
||||
|
||||
data_list = get_forecast(lat, lon, model_id)
|
||||
if not data_list: return f"❌ Errore API Meteo ({model_name})."
|
||||
if not isinstance(data_list, list): data_list = [data_list]
|
||||
|
||||
# Punto centrale (Casa) per dati specifici
|
||||
data_center = data_list[0]
|
||||
hourly_c = data_center.get("hourly", {})
|
||||
times = hourly_c.get("time", [])
|
||||
@@ -163,16 +358,24 @@ def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT")
|
||||
l_vis = safe_get_list(hourly_c, "visibility", L, 10000)
|
||||
l_uv = safe_get_list(hourly_c, "uv_index", L, 0)
|
||||
|
||||
# Estraggo anche i dati nuvole LOCALI per il tipo
|
||||
# Se è Casa e AROME non fornisce visibilità (tutti None), recuperala da best match
|
||||
if is_home and model_id == "meteofrance_seamless":
|
||||
vis_check = [v for v in l_vis if v is not None]
|
||||
if not vis_check: # Tutti None, recupera da best match
|
||||
vis_data = get_visibility_forecast(lat, lon)
|
||||
if vis_data and len(vis_data) >= L:
|
||||
l_vis = vis_data[:L]
|
||||
|
||||
# Dati nuvole LOCALI per decidere il TIPO (L, M, H, F)
|
||||
l_cl_tot_loc = safe_get_list(hourly_c, "cloud_cover", L, 0) # Copertura totale locale
|
||||
l_cl_low_loc = safe_get_list(hourly_c, "cloud_cover_low", L, 0)
|
||||
l_cl_mid_loc = safe_get_list(hourly_c, "cloud_cover_mid", L, 0)
|
||||
l_cl_hig_loc = safe_get_list(hourly_c, "cloud_cover_high", L, 0)
|
||||
|
||||
# --- DATI GLOBALI (MEDIA) ---
|
||||
# --- DATI GLOBALI (MEDIA 5 PUNTI) ---
|
||||
acc_cl_tot = [0.0] * L
|
||||
points_cl_tot = [ [] for _ in range(L) ]
|
||||
p_names = ["Casa", "Nord", "Sud", "Est", "Ovest"]
|
||||
|
||||
|
||||
for d in data_list:
|
||||
h = d.get("hourly", {})
|
||||
for i in range(L):
|
||||
@@ -181,6 +384,7 @@ def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT")
|
||||
cm = get_val(safe_get_list(h, "cloud_cover_mid", L)[i])
|
||||
ch = get_val(safe_get_list(h, "cloud_cover_high", L)[i])
|
||||
|
||||
# Calcolo robusto del totale per singolo punto
|
||||
real_point_total = max(cc, cl, cm, ch)
|
||||
|
||||
acc_cl_tot[i] += real_point_total
|
||||
@@ -189,8 +393,9 @@ def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT")
|
||||
num_points = len(data_list)
|
||||
avg_cl_tot = [x / num_points for x in acc_cl_tot]
|
||||
|
||||
# --- DEBUG MODE ---
|
||||
if debug_mode:
|
||||
output = f"🔍 **DEBUG 5 PUNTI (V10.4)**\n"
|
||||
output = f"🔍 **DEBUG METEO (v10.5)**\n"
|
||||
now_h = now_local().replace(minute=0, second=0, microsecond=0)
|
||||
idx = 0
|
||||
for i, t_str in enumerate(times):
|
||||
@@ -201,134 +406,195 @@ def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT")
|
||||
# Valori Locali
|
||||
loc_L = get_val(l_cl_low_loc[idx])
|
||||
loc_H = get_val(l_cl_hig_loc[idx])
|
||||
code_now = int(get_val(l_code[idx]))
|
||||
|
||||
output += f"Ora: {parse_time(times[idx]).strftime('%H:%M')}\n"
|
||||
output += f"📍 **LOCALE (Casa)**: L:{int(loc_L)}% | M:{int(get_val(l_cl_mid_loc[idx]))}% | H:{int(loc_H)}%\n"
|
||||
output += f"📍 **LOCALE (Casa)**: L:{int(loc_L)}% | H:{int(loc_H)}%\n"
|
||||
output += f"🌍 **MEDIA GLOBALE**: {int(avg_cl_tot[idx])}%\n"
|
||||
output += f"❄️ **NEVE**: Codice={code_now}, Accumulo={get_val(l_snow[idx])}cm\n"
|
||||
|
||||
decision = "H"
|
||||
if loc_L > 40: decision = "L (Priorità Locale)"
|
||||
output += f"👉 **Decisione**: {decision}\n"
|
||||
output += f"👉 **Decisione Nuvole**: {decision}\n"
|
||||
return output
|
||||
|
||||
now = now_local().replace(minute=0, second=0, microsecond=0)
|
||||
blocks = []
|
||||
header = f"{'LT':<2} {'T°':>4} {'h%':>3} {'mm':>3} {'Vento':<5} {'Nv%':>5} {'Sk':<2} {'Sx':<2}"
|
||||
separator = "-" * 31
|
||||
# --- GENERAZIONE TABELLA ---
|
||||
# Usa timezone personalizzata se fornita
|
||||
tz_to_use_info = ZoneInfo(tz_to_use) if tz_to_use else TZINFO
|
||||
now_local_tz = datetime.datetime.now(tz_to_use_info)
|
||||
|
||||
for (label, hours_duration, step) in [("Prime 24h", 24, 1), ("Successive 24h", 24, 2)]:
|
||||
end_time = now + datetime.timedelta(hours=hours_duration)
|
||||
lines = [header, separator]
|
||||
count = 0
|
||||
# Inizia dall'ora corrente (arrotondata all'ora)
|
||||
current_hour = now_local_tz.replace(minute=0, second=0, microsecond=0)
|
||||
|
||||
# Fine finestra: 48 ore dopo current_hour
|
||||
end_hour = current_hour + datetime.timedelta(hours=48)
|
||||
|
||||
# Raccogli tutti i timestamp validi nelle 48 ore successive
|
||||
valid_indices = []
|
||||
for i, t_str in enumerate(times):
|
||||
try:
|
||||
dt = parse_time(t_str)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=tz_to_use_info)
|
||||
else:
|
||||
dt = dt.astimezone(tz_to_use_info)
|
||||
|
||||
# Include solo timestamp >= current_hour e < end_hour
|
||||
if current_hour <= dt < end_hour:
|
||||
valid_indices.append((i, dt))
|
||||
except Exception as e:
|
||||
logger.error(f"Errore parsing timestamp {i}: {e}")
|
||||
continue
|
||||
|
||||
if not valid_indices:
|
||||
return f"❌ Nessun dato disponibile per le prossime 48 ore (da {current_hour.strftime('%H:%M')})."
|
||||
|
||||
# Separa in blocchi per giorno: cambia intestazione quando passa da 23 a 00
|
||||
blocks = []
|
||||
header = f"{'LT':<2} {'T°':>4} {'h%':>3} {'mm':<3} {'Vento':<5} {'Nv%':>5} {'Sk':<2} {'Sx':<2}"
|
||||
separator = "-" * 31
|
||||
|
||||
current_day = None
|
||||
current_block_lines = []
|
||||
hours_from_start = 0 # Contatore ore dall'inizio (0-47)
|
||||
|
||||
for idx, dt in valid_indices:
|
||||
# Determina se questo timestamp appartiene a un nuovo giorno
|
||||
# (passaggio da 23 a 00)
|
||||
day_date = dt.date()
|
||||
is_new_day = (current_day is not None and day_date != current_day)
|
||||
|
||||
for i, t_str in enumerate(times):
|
||||
try:
|
||||
dt = parse_time(t_str)
|
||||
if dt < now or dt >= end_time: continue
|
||||
if dt.hour % step != 0: continue
|
||||
|
||||
T = get_val(l_temp[i], 0)
|
||||
App = get_val(l_app[i], 0)
|
||||
Rh = int(get_val(l_rh[i], 50))
|
||||
|
||||
t_suffix = ""
|
||||
diff = App - T
|
||||
if diff <= -2.5: t_suffix = "W"
|
||||
elif diff >= 2.5: t_suffix = "H"
|
||||
t_s = f"{int(round(T))}{t_suffix}"
|
||||
# Determina se mostrare questo timestamp in base alla posizione nelle 48h
|
||||
# Prime 24h: ogni ora (step=1)
|
||||
# Dalla 25a alla 48a: ogni 2 ore (step=2)
|
||||
if hours_from_start < 24:
|
||||
step = 1 # Prime 24h: dettaglio 1 ora
|
||||
else:
|
||||
step = 2 # Dalla 25a alla 48a: dettaglio 2 ore
|
||||
|
||||
# Controlla se questo timestamp deve essere mostrato
|
||||
should_show = (hours_from_start % step == 0)
|
||||
|
||||
# Se è un nuovo giorno, chiudi il blocco precedente
|
||||
if is_new_day and current_block_lines:
|
||||
# Chiudi blocco precedente (solo se ha contenuto oltre header e separator)
|
||||
if len(current_block_lines) > 2:
|
||||
day_label = f"{['Lun','Mar','Mer','Gio','Ven','Sab','Dom'][current_day.weekday()]} {current_day.day}"
|
||||
blocks.append(f"*{day_label}*\n```text\n" + "\n".join(current_block_lines) + "\n```")
|
||||
current_block_lines = []
|
||||
|
||||
# Aggiorna current_day se è cambiato
|
||||
if current_day is None or is_new_day:
|
||||
current_day = day_date
|
||||
|
||||
# Mostra questo timestamp solo se deve essere incluso
|
||||
if should_show:
|
||||
# Se è il primo elemento di questo blocco (o primo elemento dopo cambio giorno), aggiungi header
|
||||
if not current_block_lines:
|
||||
# Assicurati che current_day corrisponda al giorno della prima riga mostrata
|
||||
current_day = day_date
|
||||
current_block_lines.append(header)
|
||||
current_block_lines.append(separator)
|
||||
# --- DATI BASE ---
|
||||
T = get_val(l_temp[idx], 0)
|
||||
App = get_val(l_app[idx], 0)
|
||||
Rh = int(get_val(l_rh[idx], 50))
|
||||
|
||||
t_suffix = ""
|
||||
diff = App - T
|
||||
if diff <= -2.5: t_suffix = "W"
|
||||
elif diff >= 2.5: t_suffix = "H"
|
||||
t_s = f"{int(round(T))}{t_suffix}"
|
||||
|
||||
Pr = get_val(l_prec[i], 0)
|
||||
Sn = get_val(l_snow[i], 0)
|
||||
Code = int(l_code[i]) if l_code[i] is not None else 0
|
||||
|
||||
p_suffix = ""
|
||||
if Code in [96, 99]: p_suffix = "G"
|
||||
elif Code in [66, 67]: p_suffix = "Z"
|
||||
elif Sn > 0 or Code in [71, 73, 75, 77, 85, 86]: p_suffix = "N"
|
||||
p_s = "--" if Pr < 0.2 else f"{int(round(Pr))}{p_suffix}"
|
||||
Pr = get_val(l_prec[idx], 0)
|
||||
Sn = get_val(l_snow[idx], 0)
|
||||
Code = int(get_val(l_code[idx], 0))
|
||||
Rain = get_val(l_rain[idx], 0)
|
||||
|
||||
# Determina se è neve
|
||||
is_snowing = Sn > 0 or (Code in [71, 73, 75, 77, 85, 86])
|
||||
|
||||
# Formattazione MM
|
||||
p_suffix = ""
|
||||
if Code in [96, 99]: p_suffix = "G"
|
||||
elif Code in [66, 67]: p_suffix = "Z"
|
||||
elif is_snowing and Pr >= 0.2: p_suffix = "N"
|
||||
|
||||
p_s = "--" if Pr < 0.2 else f"{int(round(Pr))}{p_suffix}"
|
||||
|
||||
# --- CLOUD LOGIC (V10.4: LOCAL PRIORITY) ---
|
||||
|
||||
# Usiamo la MEDIA per la quantità (Panoramica)
|
||||
c_avg_tot = int(avg_cl_tot[i])
|
||||
|
||||
# Usiamo i dati LOCALI per il tipo (Cosa ho sulla testa)
|
||||
loc_L = get_val(l_cl_low_loc[i])
|
||||
loc_M = get_val(l_cl_mid_loc[i])
|
||||
loc_H = get_val(l_cl_hig_loc[i])
|
||||
Vis = get_val(l_vis[i], 10000)
|
||||
# --- CLOUD LOGIC ---
|
||||
Cl = int(get_val(l_cl_tot_loc[idx], 0))
|
||||
Vis = get_val(l_vis[idx], 10000)
|
||||
|
||||
# Calcola tipo nuvole per get_icon_set (L/M/H/F)
|
||||
loc_L = get_val(l_cl_low_loc[idx])
|
||||
loc_M = get_val(l_cl_mid_loc[idx])
|
||||
loc_H = get_val(l_cl_hig_loc[idx])
|
||||
types = {'L': loc_L, 'M': loc_M, 'H': loc_H}
|
||||
dominant_type = max(types, key=types.get)
|
||||
|
||||
# Override: Se nubi basse locali > 40%, vincono loro
|
||||
if loc_L > 40:
|
||||
dominant_type = 'L'
|
||||
|
||||
# Step 1: Default matematico LOCALE
|
||||
types = {'L': loc_L, 'M': loc_M, 'H': loc_H}
|
||||
dominant_type = max(types, key=types.get)
|
||||
|
||||
# Quantità da mostrare: Media Globale
|
||||
Cl = c_avg_tot
|
||||
|
||||
# Step 2: Override Tattico LOCALE
|
||||
# Se LOCALMENTE le basse sono > 40%, vincono loro.
|
||||
# (Soglia abbassata a 40 per catturare il 51%)
|
||||
if loc_L > 40:
|
||||
dominant_type = 'L'
|
||||
# Se localmente è nuvoloso basso, forziamo la copertura visiva alta
|
||||
# anche se la media globale è più bassa
|
||||
if Cl < loc_L: Cl = int(loc_L)
|
||||
# Nebbia
|
||||
is_fog = False
|
||||
if Vis < 1500:
|
||||
is_fog = True
|
||||
elif Code in [45, 48]:
|
||||
is_fog = True
|
||||
|
||||
if is_fog:
|
||||
dominant_type = 'F'
|
||||
|
||||
# Formattazione Nv%
|
||||
if is_fog:
|
||||
cl_str = "FOG"
|
||||
else:
|
||||
cl_str = f"{Cl}"
|
||||
|
||||
# Step 3: Nebbia (F)
|
||||
is_fog = False
|
||||
if Vis < 2000 or Code in [45, 48]:
|
||||
is_fog = True
|
||||
elif Rh >= 96 and loc_L > 40:
|
||||
is_fog = True
|
||||
|
||||
if is_fog:
|
||||
dominant_type = 'F'
|
||||
if Cl < 100: Cl = 100
|
||||
UV = get_val(l_uv[idx], 0)
|
||||
uv_suffix = ""
|
||||
if UV >= 10: uv_suffix = "E"
|
||||
elif UV >= 7: uv_suffix = "H"
|
||||
|
||||
# Check varianza spaziale
|
||||
min_p = min(points_cl_tot[i])
|
||||
max_p = max(points_cl_tot[i])
|
||||
var_symbol = ""
|
||||
if (max_p - min_p) > 20:
|
||||
var_symbol = "~"
|
||||
# --- VENTO ---
|
||||
Wspd = get_val(l_wspd[idx], 0)
|
||||
Gust = get_val(l_gust[idx], 0)
|
||||
Wdir = int(get_val(l_wdir[idx], 0))
|
||||
Cape = get_val(l_cape[idx], 0)
|
||||
IsDay = int(get_val(l_day[idx], 1))
|
||||
|
||||
card = degrees_to_cardinal(Wdir)
|
||||
w_val = Gust if (Gust - Wspd) > 15 else Wspd
|
||||
w_txt = f"{card} {int(round(w_val))}"
|
||||
if (Gust - Wspd) > 15:
|
||||
g_txt = f"G{int(round(w_val))}"
|
||||
if len(card)+len(g_txt) <= 5: w_txt = f"{card}{g_txt}"
|
||||
elif len(card)+1+len(g_txt) <= 5: w_txt = f"{card} {g_txt}"
|
||||
else: w_txt = g_txt
|
||||
w_fmt = f"{w_txt:<5}"
|
||||
|
||||
# --- ICONE ---
|
||||
sky, sgx = get_icon_set(Pr, Sn, Code, IsDay, Cl, Vis, T, Rain, Gust, Cape, dominant_type)
|
||||
|
||||
# Se c'è precipitazione nevosa, mostra ❄️ nella colonna Sk (invece di 🌨️)
|
||||
if is_snowing and Pr >= 0.2:
|
||||
sky = "❄️"
|
||||
|
||||
sky_fmt = f"{sky}{uv_suffix}"
|
||||
|
||||
cl_str = f"{var_symbol}{Cl}{dominant_type}"
|
||||
|
||||
UV = get_val(l_uv[i], 0)
|
||||
uv_suffix = ""
|
||||
if UV >= 10: uv_suffix = "E"
|
||||
elif UV >= 7: uv_suffix = "H"
|
||||
|
||||
Wspd = get_val(l_wspd[i], 0)
|
||||
Gust = get_val(l_gust[i], 0)
|
||||
Wdir = int(get_val(l_wdir[i], 0))
|
||||
Cape = get_val(l_cape[i], 0)
|
||||
IsDay = int(l_day[i]) if l_day[i] is not None else 1
|
||||
|
||||
card = degrees_to_cardinal(Wdir)
|
||||
w_val = Gust if (Gust - Wspd) > 15 else Wspd
|
||||
w_txt = f"{card} {int(round(w_val))}"
|
||||
if (Gust - Wspd) > 15:
|
||||
g_txt = f"G{int(round(w_val))}"
|
||||
if len(card)+len(g_txt) <= 5: w_txt = f"{card}{g_txt}"
|
||||
elif len(card)+1+len(g_txt) <= 5: w_txt = f"{card} {g_txt}"
|
||||
else: w_txt = g_txt
|
||||
w_fmt = f"{w_txt:<5}"
|
||||
|
||||
sky, sgx = get_icon_set(Pr, Sn, Code, IsDay, Cl, Vis, T, get_val(l_rain[i], 0), Gust, Cape, dominant_type)
|
||||
sky_fmt = f"{sky}{uv_suffix}"
|
||||
|
||||
lines.append(f"{dt.strftime('%H'):<2} {t_s:>4} {Rh:>3} {p_s:>3} {w_fmt} {cl_str:>5} {sky_fmt:<2} {sgx:<2}")
|
||||
count += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Errore riga meteo {i}: {e}")
|
||||
continue
|
||||
|
||||
if count > 0:
|
||||
day_label = f"{['Lun','Mar','Mer','Gio','Ven','Sab','Dom'][now.weekday()]} {now.day}"
|
||||
blocks.append(f"*{day_label} ({label})*\n```text\n" + "\n".join(lines) + "\n```")
|
||||
now = end_time
|
||||
current_block_lines.append(f"{dt.strftime('%H'):<2} {t_s:>4} {Rh:>3} {p_s:>3} {w_fmt} {cl_str:>5} {sky_fmt:<2} {sgx:<2}")
|
||||
|
||||
hours_from_start += 1
|
||||
|
||||
# Chiudi ultimo blocco (solo se ha contenuto oltre header e separator)
|
||||
if current_block_lines and len(current_block_lines) > 2: # Header + separator + almeno 1 riga dati
|
||||
day_label = f"{['Lun','Mar','Mer','Gio','Ven','Sab','Dom'][current_day.weekday()]} {current_day.day}"
|
||||
blocks.append(f"*{day_label}*\n```text\n" + "\n".join(current_block_lines) + "\n```")
|
||||
|
||||
if not blocks:
|
||||
return f"❌ Nessun dato da mostrare nelle prossime 48 ore (da {current_hour.strftime('%H:%M')})."
|
||||
|
||||
return f"🌤️ *METEO REPORT*\n📍 {location_name}\n🧠 Fonte: {model_name}\n\n" + "\n\n".join(blocks)
|
||||
|
||||
@@ -337,16 +603,41 @@ if __name__ == "__main__":
|
||||
args_parser.add_argument("--query", help="Nome città")
|
||||
args_parser.add_argument("--home", action="store_true", help="Usa coordinate casa")
|
||||
args_parser.add_argument("--debug", action="store_true", help="Mostra i valori dei 5 punti")
|
||||
args_parser.add_argument("--chat_id", help="Chat ID Telegram per invio diretto (opzionale, può essere multiplo separato da virgola)")
|
||||
args_parser.add_argument("--timezone", help="Timezone IANA (es: Europe/Rome, America/New_York)")
|
||||
args = args_parser.parse_args()
|
||||
|
||||
# Determina chat_ids se specificato
|
||||
chat_ids = None
|
||||
if args.chat_id:
|
||||
chat_ids = [cid.strip() for cid in args.chat_id.split(",") if cid.strip()]
|
||||
|
||||
# Genera report
|
||||
report = None
|
||||
if args.home:
|
||||
print(generate_weather_report(HOME_LAT, HOME_LON, HOME_NAME, args.debug, "SM"))
|
||||
report = generate_weather_report(HOME_LAT, HOME_LON, HOME_NAME, args.debug, "SM")
|
||||
elif args.query:
|
||||
coords = get_coordinates(args.query)
|
||||
if coords:
|
||||
lat, lon, name, cc = coords
|
||||
print(generate_weather_report(lat, lon, name, args.debug, cc))
|
||||
report = generate_weather_report(lat, lon, name, args.debug, cc)
|
||||
else:
|
||||
print(f"❌ Città '{args.query}' non trovata.")
|
||||
error_msg = f"❌ Città '{args.query}' non trovata."
|
||||
if chat_ids:
|
||||
telegram_send_markdown(error_msg, chat_ids)
|
||||
else:
|
||||
print(error_msg)
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("Uso: meteo.py --query 'Nome Città' oppure --home [--debug]")
|
||||
usage_msg = "Uso: meteo.py --query 'Nome Città' oppure --home [--debug] [--chat_id ID]"
|
||||
if chat_ids:
|
||||
telegram_send_markdown(usage_msg, chat_ids)
|
||||
else:
|
||||
print(usage_msg)
|
||||
sys.exit(1)
|
||||
|
||||
# Invia o stampa
|
||||
if chat_ids:
|
||||
telegram_send_markdown(report, chat_ids)
|
||||
else:
|
||||
print(report)
|
||||
Reference in New Issue
Block a user