diff --git a/services/telegram-bot/basemap_tiles.py b/services/telegram-bot/basemap_tiles.py new file mode 100644 index 0000000..69957d5 --- /dev/null +++ b/services/telegram-bot/basemap_tiles.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Basemap tiles for matplotlib maps (road weather, ice, snow radar). + +Prefer tile sources that do **not** require an API key: + +- OpenStreetMap Mapnik — OK with an identifying User-Agent + (contextily's default UA gets "403 Access blocked" tiles) +- Esri WorldStreetMap — free for reasonable use, good road detail +- OpenTopoMap — terrain fallback + +CARTO basemaps are avoided: they now overlay "API KEY REQUIRED". + +Compatible with contextily 1.6.x (loogle-casa) and 1.7.x (loogle-bot): +in 1.6, ``headers=`` is forwarded to ``imshow`` and breaks the call, +so we set ``contextily.tile.USER_AGENT`` instead. +""" + +from __future__ import annotations + +import inspect +import logging +from typing import Any, Optional + +LOGGER = logging.getLogger("basemap_tiles") + +BASEMAP_USER_AGENT = "LoogleCasa/1.0 (https://casa.loogle.it; meteo maps)" +BASEMAP_HEADERS = {"User-Agent": BASEMAP_USER_AGENT} + + +def _configure_user_agent() -> None: + try: + import contextily.tile as tile_mod + + tile_mod.USER_AGENT = BASEMAP_USER_AGENT + except Exception as exc: # noqa: BLE001 + LOGGER.debug("Impossibile impostare USER_AGENT contextily: %s", exc) + + +def _providers(ctx) -> list: + """Ordered list of free (no API key) basemap providers.""" + out = [] + candidates = [ + ("OpenStreetMap", "Mapnik"), + ("Esri", "WorldStreetMap"), + ("OpenTopoMap",), + ("OpenStreetMap", "DE"), + ("OpenStreetMap", "HOT"), + ] + for path in candidates: + try: + node = ctx.providers + for part in path: + node = getattr(node, part) + # skip leaf namespaces that aren't a provider dict + if hasattr(node, "url") or (isinstance(node, dict) and "url" in node): + out.append(node) + elif path == ("OpenTopoMap",): + # xyzservices LeafMap style + out.append(node) + except AttributeError: + continue + return out + + +def add_basemap( + ax: Any, + *, + alpha: float = 0.85, + attribution_size: int = 6, + crs: str = "EPSG:4326", +) -> bool: + """Add a web basemap under *ax*. Returns True on success.""" + try: + import contextily as ctx + except ImportError: + LOGGER.warning("contextily non disponibile: mappa senza basemap") + return False + + _configure_user_agent() + providers = _providers(ctx) + if not providers: + LOGGER.warning("Nessun provider basemap trovato in contextily") + return False + + call_kwargs: dict = { + "crs": crs, + "alpha": alpha, + "attribution_size": attribution_size, + } + try: + if "headers" in inspect.signature(ctx.add_basemap).parameters: + call_kwargs["headers"] = BASEMAP_HEADERS + except (TypeError, ValueError): + pass + + last_err: Optional[BaseException] = None + for source in providers: + name = getattr(source, "name", None) or ( + source.get("name") if isinstance(source, dict) else str(source) + ) + try: + ctx.add_basemap(ax, source=source, **call_kwargs) + LOGGER.info("Basemap OK: %s", name) + return True + except Exception as exc: # noqa: BLE001 + last_err = exc + LOGGER.warning("Basemap %s fallita: %s", name, exc) + + if last_err: + LOGGER.warning("Nessuna basemap disponibile: %s", last_err) + return False diff --git a/services/telegram-bot/check_ghiaccio.py b/services/telegram-bot/check_ghiaccio.py index 8c98c1f..dc431d6 100644 --- a/services/telegram-bot/check_ghiaccio.py +++ b/services/telegram-bot/check_ghiaccio.py @@ -1673,13 +1673,14 @@ def generate_route_ice_map(df: pd.DataFrame, city1: str, city2: str, output_path print(f"matplotlib non disponibile: {e}. Mappa non generata.") return False - # Prova a importare contextily per mappa di sfondo + # Prova a importare basemap (CARTO / OSM HOT; non tile.openstreetmap.org) try: - import contextily as ctx + from basemap_tiles import add_basemap CONTEXTILY_AVAILABLE = True except ImportError: CONTEXTILY_AVAILABLE = False - print("contextily non disponibile. Mappa generata senza sfondo geografico.") + add_basemap = None # type: ignore + print("contextily/basemap_tiles non disponibile. Mappa generata senza sfondo geografico.") if df.empty: return False @@ -1720,13 +1721,10 @@ def generate_route_ice_map(df: pd.DataFrame, city1: str, city2: str, output_path ax.set_ylim(lat_min, lat_max) ax.set_aspect('equal', adjustable='box') - # Aggiungi mappa di sfondo OpenStreetMap se disponibile - if CONTEXTILY_AVAILABLE: - try: - ctx.add_basemap(ax, crs='EPSG:4326', source=ctx.providers.OpenStreetMap.Mapnik, - alpha=0.6, attribution_size=6) - except Exception as e: - print(f"Errore aggiunta mappa sfondo: {e}") + # Aggiungi mappa di sfondo (CARTO; OSM Mapnik è bloccato per lo UA di contextily) + if CONTEXTILY_AVAILABLE and add_basemap is not None: + if not add_basemap(ax, alpha=0.85, attribution_size=6): + print("Errore aggiunta mappa sfondo") CONTEXTILY_AVAILABLE = False # Disegna linea del percorso @@ -1871,13 +1869,14 @@ def generate_ice_risk_map(points_data: List[Dict], output_path: str) -> bool: print(f"matplotlib non disponibile: {e}. Mappa non generata.") return False - # Prova a importare contextily per mappa di sfondo + # Prova a importare basemap (CARTO / OSM HOT; non tile.openstreetmap.org) try: - import contextily as ctx + from basemap_tiles import add_basemap CONTEXTILY_AVAILABLE = True except ImportError: CONTEXTILY_AVAILABLE = False - print("contextily non disponibile. Mappa generata senza sfondo geografico.") + add_basemap = None # type: ignore + print("contextily/basemap_tiles non disponibile. Mappa generata senza sfondo geografico.") if not points_data: return False @@ -1901,13 +1900,10 @@ def generate_ice_risk_map(points_data: List[Dict], output_path: str) -> bool: ax.set_ylim(lat_min, lat_max) ax.set_aspect('equal', adjustable='box') - # Aggiungi mappa di sfondo OpenStreetMap se disponibile - if CONTEXTILY_AVAILABLE: - try: - ctx.add_basemap(ax, crs='EPSG:4326', source=ctx.providers.OpenStreetMap.Mapnik, - alpha=0.6, attribution_size=6) - except Exception as e: - print(f"Errore aggiunta mappa sfondo: {e}") + # Aggiungi mappa di sfondo (CARTO; OSM Mapnik è bloccato per lo UA di contextily) + if CONTEXTILY_AVAILABLE and add_basemap is not None: + if not add_basemap(ax, alpha=0.85, attribution_size=6): + print("Errore aggiunta mappa sfondo") CONTEXTILY_AVAILABLE = False # Colori per livelli di rischio: verde (0), giallo (1), arancione (2), rosso scuro (3), azzurro (4=neve) diff --git a/services/telegram-bot/road_weather.py b/services/telegram-bot/road_weather.py index 3cd744f..98603c4 100644 --- a/services/telegram-bot/road_weather.py +++ b/services/telegram-bot/road_weather.py @@ -1793,10 +1793,11 @@ def generate_route_weather_map(df: pd.DataFrame, city1: str, city2: str, output_ return False try: - import contextily as ctx + from basemap_tiles import add_basemap CONTEXTILY_AVAILABLE = True except ImportError: CONTEXTILY_AVAILABLE = False + add_basemap = None # type: ignore if df.empty: return False @@ -1868,11 +1869,10 @@ def generate_route_weather_map(df: pd.DataFrame, city1: str, city2: str, output_ ax.set_ylim(lat_min, lat_max) ax.set_aspect('equal', adjustable='box') - if CONTEXTILY_AVAILABLE: - try: - ctx.add_basemap(ax, crs='EPSG:4326', source=ctx.providers.OpenStreetMap.Mapnik, - alpha=0.6, attribution_size=6) - except: + if CONTEXTILY_AVAILABLE and add_basemap is not None: + # Non usare tile.openstreetmap.org: OSM blocca lo User-Agent di contextily + # e restituisce tile "403 Access blocked" (vedi osm.wiki/Blocked). + if not add_basemap(ax, alpha=0.85, attribution_size=6): CONTEXTILY_AVAILABLE = False # Linea percorso diff --git a/services/telegram-bot/snow_radar.py b/services/telegram-bot/snow_radar.py index 26d1811..c21e217 100755 --- a/services/telegram-bot/snow_radar.py +++ b/services/telegram-bot/snow_radar.py @@ -340,13 +340,14 @@ def generate_snow_map(results: List[Dict], center_lat: float, center_lon: float, LOGGER.warning("matplotlib non disponibile: %s. Mappa non generata.", e) return False - # Prova a importare contextily per mappa di sfondo + # Prova a importare basemap (CARTO / OSM HOT; non tile.openstreetmap.org) try: - import contextily as ctx + from basemap_tiles import add_basemap CONTEXTILY_AVAILABLE = True except ImportError: CONTEXTILY_AVAILABLE = False - LOGGER.warning("contextily non disponibile. Mappa generata senza sfondo geografico.") + add_basemap = None # type: ignore + LOGGER.warning("contextily/basemap_tiles non disponibile. Mappa generata senza sfondo geografico.") if not results: return False @@ -377,16 +378,12 @@ def generate_snow_map(results: List[Dict], center_lat: float, center_lon: float, ax.set_ylim(lat_min, lat_max) ax.set_aspect('equal', adjustable='box') - # Aggiungi mappa di sfondo OpenStreetMap se disponibile - if CONTEXTILY_AVAILABLE: - try: - # Aggiungi tile OpenStreetMap (contextily gestisce automaticamente la conversione) - ctx.add_basemap(ax, crs='EPSG:4326', source=ctx.providers.OpenStreetMap.Mapnik, - alpha=0.6, attribution_size=6) - LOGGER.debug("Mappa OpenStreetMap aggiunta come sfondo") - except Exception as e: - LOGGER.warning("Errore aggiunta mappa sfondo: %s. Continuo senza sfondo.", e) - # Non reimpostare CONTEXTILY_AVAILABLE qui, solo logga l'errore + # Aggiungi mappa di sfondo (CARTO; OSM Mapnik è bloccato per lo UA di contextily) + if CONTEXTILY_AVAILABLE and add_basemap is not None: + if add_basemap(ax, alpha=0.85, attribution_size=6): + LOGGER.debug("Basemap aggiunta come sfondo") + else: + LOGGER.warning("Errore aggiunta mappa sfondo. Continuo senza sfondo.") # Disegna punti con colore basato su accumulo totale # Colori: blu (poco) -> verde -> giallo -> arancione -> rosso (molto)