SnowScanner / app.py
mirix's picture
Upload app.py
d423a9d verified
### app_fixed.py ###
import gradio as gr
import pandas as pd
import numpy as np
import json
from datetime import datetime, timezone
import geopy
from geopy import distance
from geopy.geocoders import Nominatim
import srtm
import requests
import requests_cache
import openmeteo_requests
from retry_requests import retry
import plotly.graph_objects as go
# --- GLOBAL SETUP ---
elevation_data = srtm.get_data()
with open("weather_icons_custom.json", "r") as f:
icons = json.load(f)
cache_session = requests_cache.CachedSession(".cache", expire_after=3600)
retry_session = retry(cache_session, retries=5, backoff_factor=0.2)
openmeteo = openmeteo_requests.Client(session=retry_session)
geolocator = Nominatim(user_agent="snow_finder")
OVERPASS_URL = "https://maps.mail.ru/osm/tools/overpass/api/interpreter"
ICON_URL = "https://raw.githubusercontent.com/basmilius/weather-icons/refs/heads/dev/production/fill/svg/"
DEFAULT_LAT, DEFAULT_LON = 49.6116, 6.1319
# --- UTILS ---
def compute_bbox(lat, lon, dist_km):
"""Compute bounding box more reliably for any location."""
# Convert km to degrees (rough approximation)
# At equator: 1 degree ≈ 111 km
lat_delta = dist_km / 111.0
lon_delta = dist_km / (111.0 * np.cos(np.radians(lat)))
south = lat - lat_delta
north = lat + lat_delta
west = lon - lon_delta
east = lon + lon_delta
# Ensure longitude wraps properly
if west < -180:
west += 360
if east > 180:
east -= 360
# Ensure latitude stays in valid range
south = max(south, -90)
north = min(north, 90)
return f"{south},{west},{north},{east}"
def get_elevation_from_srtm(lat, lon):
"""Get elevation from SRTM if within coverage area."""
if lat is None or lon is None:
return None
# SRTM coverage: 60°N to 56°S
if -56 <= lat <= 60:
try:
alt = elevation_data.get_elevation(lat, lon)
if alt is not None and alt > 0:
return alt
except Exception as ex:
print(f"SRTM error for {lat},{lon}: {ex}")
return None
def get_peaks_from_overpass(lat, lon, dist_km):
"""Query Overpass API for nearby peaks and hills."""
bbox = compute_bbox(lat, lon, dist_km)
query = f"""
[out:json];
(
nwr[natural=peak]({bbox});
nwr[natural=hill]({bbox});
);
out body;
"""
try:
r = requests.get(OVERPASS_URL, params={"data": query}, timeout=30)
r.raise_for_status()
data = r.json()
except Exception as e:
print(f"Error fetching peaks: {e}")
return pd.DataFrame()
peaks = {"name": [], "latitude": [], "longitude": [], "altitude": []}
skipped = 0
processed = 0
max_peaks = 100 # Limit processing to avoid slowdowns
for e in data.get("elements", []):
# Stop if we've processed enough peaks
if processed >= max_peaks:
break
lat_e, lon_e = e.get("lat"), e.get("lon")
# Skip elements without valid coordinates
if lat_e is None or lon_e is None:
skipped += 1
continue
tags = e.get("tags", {})
alt = None
# Strategy 1: Try to get elevation from OSM tag first
ele = tags.get("ele")
if ele and str(ele).replace(".", "").replace("-", "").isnumeric():
alt = float(ele)
# Strategy 2: If no OSM elevation, try SRTM as fallback
if alt is None or alt <= 10:
alt = get_elevation_from_srtm(lat_e, lon_e)
# Skip peaks if both strategies failed to produce valid elevation
if alt is None or alt <= 10:
skipped += 1
continue
peaks["latitude"].append(lat_e)
peaks["longitude"].append(lon_e)
peaks["name"].append(tags.get("name", "Unnamed Peak/Hill"))
peaks["altitude"].append(alt)
processed += 1
if skipped > 0:
print(f"Skipped {skipped} peaks without complete data (coordinates or elevation)")
if processed >= max_peaks:
print(f"Reached limit of {max_peaks} peaks processed")
if not peaks["latitude"]:
return pd.DataFrame()
df = pd.DataFrame(peaks)
df["altitude"] = df["altitude"].round(0).astype(int)
df["distance_m"] = df.apply(
lambda r: distance.distance((r["latitude"], r["longitude"]), (lat, lon)).m, axis=1
)
return df
# --- WEATHER FETCH (STRING PARAMS VERSION) ---
def get_weather_for_peaks_iteratively(df_peaks, min_snow_cm, max_results=20, max_requests=100):
"""Fetch weather for peaks with all params as strings to avoid iteration errors."""
if df_peaks.empty:
return pd.DataFrame()
url = "https://api.open-meteo.com/v1/forecast"
results, requests_made = [], 0
for _, row in df_peaks.iterrows():
if len(results) >= max_results or requests_made >= max_requests:
break
params = {
"latitude": str(row["latitude"]),
"longitude": str(row["longitude"]),
"elevation": str(row["altitude"]),
"hourly": "temperature_2m,is_day,weather_code,snow_depth",
"forecast_days": "1",
"timezone": "auto",
}
try:
responses = openmeteo.weather_api(url, params=params)
if not responses:
continue
response = responses[0]
hourly = response.Hourly()
if hourly is None:
continue
idx = 0
temp_c = float(hourly.Variables(0).ValuesAsNumpy()[idx])
is_day = int(hourly.Variables(1).ValuesAsNumpy()[idx])
weather_code = int(hourly.Variables(2).ValuesAsNumpy()[idx])
snow_depth_m = float(hourly.Variables(3).ValuesAsNumpy()[idx])
snow_depth_cm = snow_depth_m * 100
if snow_depth_cm >= min_snow_cm:
results.append({
**row.to_dict(),
"temp_c": temp_c,
"is_day": is_day,
"weather_code": weather_code,
"snow_depth_m": snow_depth_m,
"snow_depth_cm": int(np.round(snow_depth_cm, 0))
})
except Exception as e:
print(f"Error fetching weather for {row['name']} at {row['latitude']},{row['longitude']}: {e}")
requests_made += 1
return pd.DataFrame(results)
# --- POST-PROCESSING ---
def format_weather_data(df):
if df.empty:
return df
def icon_mapper(row):
code = str(int(row["weather_code"]))
tod = "day" if row["is_day"] == 1 else "night"
info = icons.get(code, {}).get(tod, {})
icon_filename = info.get("icon", "")
description = info.get("description", "Unknown")
return ICON_URL + icon_filename, description, icon_filename
df[["weather_icon_url", "weather_desc", "weather_icon_name"]] = df.apply(
icon_mapper, axis=1, result_type="expand"
)
df["distance_km"] = (df["distance_m"] / 1000).round(1)
df["temp_c_str"] = df["temp_c"].round(0).astype(int).astype(str) + "°C"
return df
def geocode_location(location_text):
try:
loc = geolocator.geocode(location_text, timeout=10)
if loc:
return loc.latitude, loc.longitude, f"Found: {loc.address}"
return None, None, f"Location '{location_text}' not found."
except Exception as e:
return None, None, f"Geocoding error: {e}"
# --- CORE LOGIC ---
def find_snowy_peaks(min_snow_cm, radius_km, lat, lon):
if lat is None or lon is None:
fig = create_empty_map(DEFAULT_LAT, DEFAULT_LON)
fig.update_layout(title_text="Enter valid coordinates.")
return fig, "Please enter coordinates."
if not (-90 <= lat <= 90 and -180 <= lon <= 180):
fig = create_empty_map(DEFAULT_LAT, DEFAULT_LON)
fig.update_layout(title_text="Invalid coordinates.")
return fig, "Coordinates out of range."
df_peaks = get_peaks_from_overpass(lat, lon, radius_km)
if df_peaks.empty:
fig = create_map_with_center(lat, lon)
fig.update_layout(title_text=f"No peaks found within {radius_km} km.")
return fig, f"No peaks found within {radius_km} km."
df_peaks = df_peaks.sort_values("distance_m").reset_index(drop=True)
df_weather = get_weather_for_peaks_iteratively(df_peaks, min_snow_cm)
if df_weather.empty:
fig = create_map_with_center(lat, lon)
fig.update_layout(title_text=f"No snowy peaks ≥ {min_snow_cm} cm.")
return fig, f"No peaks met the ≥ {min_snow_cm} cm snow requirement."
df_final = format_weather_data(df_weather)
fig = create_map_with_results(lat, lon, df_final)
fig.update_layout(title_text=f"Found {len(df_final)} snowy peaks!")
msg = f"🎉 Showing {len(df_final)} snowy peaks with ≥ {min_snow_cm} cm of snow."
return fig, msg
# --- MAP HELPERS ---
def create_empty_map(lat, lon):
fig = go.Figure()
fig.update_layout(
map=dict(style="open-street-map", center={"lat": lat, "lon": lon}, zoom=8),
margin={"r": 0, "t": 40, "l": 0, "b": 0},
height=1024,
width=1024,
)
return fig
def create_map_with_center(lat, lon):
fig = go.Figure(
go.Scattermap(
lat=[lat],
lon=[lon],
mode="markers",
marker=dict(size=24, color="white", opacity=0.8),
hoverinfo="skip",
)
)
fig.add_trace(
go.Scattermap(
lat=[lat],
lon=[lon],
mode="markers",
marker=dict(size=12, color="red"),
text=["Search Center"],
hoverinfo="text",
)
)
fig.update_layout(
map=dict(style="open-street-map", center={"lat": lat, "lon": lon}, zoom=8),
margin={"r": 0, "t": 40, "l": 0, "b": 0},
height=1024,
width=1024,
)
return fig
def create_map_with_results(lat, lon, df_final):
fig = go.Figure()
# Add white halos for peaks
fig.add_trace(
go.Scattermap(
lat=df_final["latitude"],
lon=df_final["longitude"],
mode="markers",
marker=dict(size=24, color="white", opacity=0.8),
hoverinfo="skip",
)
)
# Add peak markers with weather info in hover (no HTML icons)
fig.add_trace(
go.Scattermap(
lat=df_final["latitude"],
lon=df_final["longitude"],
mode="markers",
marker=dict(size=12, color="blue"),
customdata=df_final[
["name", "altitude", "distance_km", "snow_depth_cm", "weather_desc",
"temp_c_str"]
],
hovertemplate=(
"<b>%{customdata[0]}</b><br>"
"Altitude: %{customdata[1]} m<br>"
"Distance: %{customdata[2]} km<br>"
"<b>❄️ Snow: %{customdata[3]} cm</b><br>"
"Weather: %{customdata[4]}<br>"
"🌡 Temp: %{customdata[5]}<extra></extra>"
),
)
)
# Add search center with halo
fig.add_trace(
go.Scattermap(
lat=[lat],
lon=[lon],
mode="markers",
marker=dict(size=24, color="white", opacity=0.8),
hoverinfo="skip",
)
)
fig.add_trace(
go.Scattermap(
lat=[lat],
lon=[lon],
mode="markers",
marker=dict(size=12, color="red"),
text=["Search Center"],
hoverinfo="text",
)
)
fig.update_layout(
map=dict(style="open-street-map", center={"lat": lat, "lon": lon}, zoom=9),
margin={"r": 0, "t": 40, "l": 0, "b": 0},
height=1024,
width=1024,
showlegend=False,
)
return fig
# --- GRADIO UI ---
with gr.Blocks(theme=gr.themes.Soft(), title="Snow Finder") as demo:
gr.Markdown("# ☃️ Snow Finder for Families")
gr.Markdown("Find nearby snowy peaks perfect for sledding and snowmen!")
with gr.Row():
with gr.Column(scale=1):
location_search = gr.Textbox(label="Search Location")
search_location_btn = gr.Button("🔍 Find Location")
lat_input = gr.Number(value=DEFAULT_LAT, label="Latitude", precision=4)
lon_input = gr.Number(value=DEFAULT_LON, label="Longitude", precision=4)
snow_slider = gr.Radio(choices=[1, 2, 3, 4, 5, 6], value=1, label="Min Snow (cm)")
radius_slider = gr.Radio(choices=[10, 20, 30, 40, 50, 60], value=30, label="Radius (km)")
search_button = gr.Button("❄️ Find Snow!", variant="primary")
status_output = gr.Textbox(lines=4, interactive=False)
with gr.Column(scale=2):
init_fig = create_map_with_center(DEFAULT_LAT, DEFAULT_LON)
init_fig.update_layout(title_text="Luxembourg City – Click 'Find Snow!' to start")
map_plot = gr.Plot(init_fig, label="Map")
search_location_btn.click(
fn=geocode_location, inputs=[location_search], outputs=[lat_input, lon_input, status_output]
)
search_button.click(
fn=find_snowy_peaks,
inputs=[snow_slider, radius_slider, lat_input, lon_input],
outputs=[map_plot, status_output],
)
if __name__ == "__main__":
demo.launch()