"""ClearCast Space: EXPERIMENTAL underwater visibility estimate for Finistère (satellite Secchi depth -> diver visibility).""" import joblib, numpy as np, pandas as pd, requests, gradio as gr import plotly.graph_objects as go from features import daily_weather_from_hourly from spots import SPOTS MODELS = joblib.load("zsd_models.joblib") LATEST = pd.read_csv("latest.csv").set_index("spot") HIND = pd.read_parquet("hindcast.parquet") NAMES = list(SPOTS) K, LINK = 0.76, {"0.1": -0.731, "0.5": 0.008, "0.9": 0.597} SD_LINK = (LINK["0.9"] - LINK["0.1"]) / 2.563 LABEL = ("EXPERIMENTAL estimate. Visibility ≈ 0.76 × satellite Secchi depth, a relation checked against ~6,000 diver surveys " "(Reef Life Survey) worldwide, NOT in Brittany. No validation against Finistère visibility observations exists yet.") def live_weather(lat, lon): base = dict(latitude=lat, longitude=lon, past_days=10, forecast_days=1, timezone="UTC") a = requests.get("https://api.open-meteo.com/v1/forecast", params=dict(base, hourly="wind_speed_10m,wind_direction_10m,precipitation"), timeout=30).json() m = requests.get("https://marine-api.open-meteo.com/v1/marine", params=dict(base, hourly="wave_height,wave_period,wave_direction"), timeout=30).json() w = pd.DataFrame(a["hourly"]).merge(pd.DataFrame(m["hourly"]), on="time") w["time"] = pd.to_datetime(w["time"]) return w[w["time"] <= pd.Timestamp.utcnow().tz_localize(None)] def predict(spot, horizon, last_zsd): lat, lon, _ = SPOTS[spot] h = int(horizon) d = daily_weather_from_hourly(live_weather(lat, lon)) today = d.iloc[-1:].copy() today["last_kd"] = np.log(float(last_zsd)) today["last_age"] = 0 doy = pd.Timestamp.utcnow().dayofyear today["doy_sin"], today["doy_cos"] = np.sin(2 * np.pi * doy / 365.25), np.cos(2 * np.pi * doy / 365.25) today["lat"], today["lon"] = lat, lon m = MODELS[h] X = today[m["features"]] mg = m["conformal_margin"] l10, l50, l90 = (float(m["q"][k].predict(X)[0]) for k in ("0.1", "0.5", "0.9")) l10, l90 = l10 - mg, l90 + mg sd = np.sqrt(((l90 - l10) / 2.563) ** 2 + SD_LINK ** 2) mu = l50 + np.log(K) + LINK["0.5"] v10, v50, v90 = (float(np.exp(mu + z * sd)) for z in (-1.2816, 0, 1.2816)) c = d.iloc[-1][["hs_max", "tp_mean", "wind", "rain_r3"]] return (f"### {spot}: estimated visibility in +{h} day(s)\n**≈ {v50:.0f} m** (80% range {v10:.0f} – {v90:.0f} m)\n\n" f"Forecast satellite Secchi depth ≈ {np.exp(l50):.1f} m. Today: Hs max {c.hs_max:.1f} m, period {c.tp_mean:.1f} s, " f"wind {c.wind:.1f} km/h, rain 3-day mean {c.rain_r3:.1f} mm.\n\n" f"Starting Secchi depth used: {float(last_zsd):.1f} m. Snapshot: {LATEST.loc[spot,'last_zsd']:.1f} m from **{LATEST.loc[spot,'last_date']}** " f"(stale: enter a recent value if you have one).\n\n> {LABEL}") def hindcast_plot(spot): d = HIND[HIND.spot == spot].sort_values("date") f = go.Figure() f.add_traces([go.Scatter(x=d.date, y=d.p90, line=dict(width=0), showlegend=False), go.Scatter(x=d.date, y=d.p10, fill="tonexty", line=dict(width=0), name="80% range", fillcolor="rgba(70,130,180,0.25)"), go.Scatter(x=d.date, y=d.p50, name="Estimated visibility (24 h ahead)", line=dict(color="steelblue")), go.Scatter(x=d.date, y=d.zsd_equiv_vis, mode="markers", name="0.76 × satellite Secchi (not a diver measurement)", marker=dict(color="black", size=4))]) f.update_layout(title=f"2026 test period: {spot}", yaxis_title="Visibility (m)", height=380, margin=dict(l=10, r=10, t=40, b=10)) return f with gr.Blocks(title="ClearCast") as demo: gr.Markdown("# ClearCast — Finistère underwater visibility (experimental)\n" + LABEL + "\n\nPipeline: LightGBM quantile forecast of 4 km satellite Secchi depth (Copernicus Marine) from waves, wind and rain (Open-Meteo), " "then a physical scaling to visibility with uncertainty from the diver-survey calibration. Skill vs persistence is small (see model card).") with gr.Row(): spot = gr.Dropdown(NAMES, value="Brest Iroise", label="Spot") hz = gr.Radio([1, 2, 3], value=1, label="Horizon (days)") lk = gr.Number(value=round(float(LATEST.loc["Brest Iroise", "median_zsd"]), 1), label="Latest satellite Secchi depth (m)") btn = gr.Button("Estimate visibility", variant="primary") out = gr.Markdown() plot = gr.Plot() btn.click(predict, [spot, hz, lk], out) spot.change(lambda s: (hindcast_plot(s), round(float(LATEST.loc[s, "median_zsd"]), 1)), spot, [plot, lk]) demo.load(hindcast_plot, spot, plot) if __name__ == "__main__": demo.launch()