Download ui/tab_forecast.py from SolarWine/api: direct link, hf CLI and curl.
- Browser
- Download file 29.7 kB
-
https://huggingface.co/spaces/SolarWine/api/resolve/main/ui/tab_forecast.py
- Command line
-
hf download hf://spaces/SolarWine/api/ui/tab_forecast.py
-
curl -L -o tab_forecast.py https://huggingface.co/spaces/SolarWine/api/resolve/main/ui/tab_forecast.py
29.7 kB
| """ | |
| Forecasting tab: prediction metrics, time-series, Chronos-2, routing, day-ahead advisory. | |
| """ | |
| from __future__ import annotations | |
| from pathlib import Path | |
| import pandas as pd | |
| import streamlit as st | |
| from config import settings | |
| from ui.bootstrap import _BRAND_GREEN, _HAS_PLOTLY, load_metrics | |
| if _HAS_PLOTLY: | |
| import plotly.express as px | |
| import plotly.graph_objects as go | |
| from plotly.subplots import make_subplots | |
| def render_tab_forecast() -> None: | |
| st.header("How well can we predict vine activity from weather?") | |
| st.markdown( | |
| "Optimizing panel tilt angles requires knowing vine photosynthesis **ahead " | |
| "of time** — the tracker schedule for tomorrow must be planned today. " | |
| "This tab shows how well different prediction methods forecast A from " | |
| "on-site sensors and IMS weather data, and how accuracy varies with " | |
| "forecast horizon." | |
| ) | |
| with st.expander("What models are compared?"): | |
| st.markdown( | |
| "**Cross-sectional models** predict A from weather at the same time step, " | |
| "with no memory of past values. Models: **Linear Regression**, **Decision Tree**, " | |
| "**Random Forest**, **Gradient Boosting**, and **XGBoost**.\n\n" | |
| "**Time-series models** add lagged values of A and weather (past 15 min to 3 hours) " | |
| "as features, and are trained per forecast horizon (15 min to 1 month). " | |
| "This tests whether knowing recent photosynthesis history improves predictions." | |
| ) | |
| metrics_path = settings.OUTPUTS_DIR / "stage2_metrics.csv" | |
| pred_img = settings.OUTPUTS_DIR / "stage2_predictions.png" | |
| imp_path = settings.OUTPUTS_DIR / "stage2_feature_importance.csv" | |
| if metrics_path.exists(): | |
| eval_df = load_metrics(str(metrics_path)) | |
| with st.expander("Understanding the metrics"): | |
| st.markdown( | |
| "- **RMSE (Root Mean Squared Error):** Average prediction error in the same units as A " | |
| "(\u00b5mol m\u207b\u00b2 s\u207b\u00b9). Lower is better. Penalizes large errors more than MAE.\n" | |
| "- **MAE (Mean Absolute Error):** Average absolute prediction error. Lower is better. " | |
| "More robust to outliers than RMSE.\n" | |
| "- **R\u00b2 (R-squared):** Proportion of variance in A explained by the model. " | |
| "1.0 = perfect, 0.0 = no better than predicting the mean, negative = worse than the mean.\n\n" | |
| "The **Dummy** model serves as the baseline: any useful model should beat it on all metrics." | |
| ) | |
| st.dataframe(eval_df, hide_index=True) | |
| # Plain-language interpretation of best R² | |
| if "R2" in eval_df.columns: | |
| _best_r2 = eval_df["R2"].max() | |
| if _best_r2 > 0.8: | |
| st.success( | |
| f"The best model explains **{_best_r2*100:.0f}%** of the variation in " | |
| "photosynthesis — a strong result that suggests weather data alone " | |
| "can be a useful predictor." | |
| ) | |
| elif _best_r2 > 0.5: | |
| st.info( | |
| f"The best model explains **{_best_r2*100:.0f}%** of the variation. " | |
| "Useful as a rough estimate, but there is room for improvement." | |
| ) | |
| else: | |
| st.warning( | |
| f"The best model explains only **{_best_r2*100:.0f}%** of the variation. " | |
| "Weather data alone may not capture the full picture." | |
| ) | |
| st.download_button( | |
| "Download metrics CSV", | |
| eval_df.to_csv(index=False), | |
| file_name="stage2_metrics.csv", | |
| mime="text/csv", | |
| ) | |
| if pred_img.exists(): | |
| with st.expander("How to read the prediction plots"): | |
| st.markdown( | |
| "**Left \u2014 Predicted vs Approx A scatter:** Each dot is one test-set observation. " | |
| "Points on the dashed 1:1 line are perfect predictions. Spread around the line " | |
| "indicates prediction error.\n\n" | |
| "**Right \u2014 Time series overlay:** Approx A (blue) vs model predictions over time. " | |
| "Shows whether the model captures temporal patterns (diurnal cycles, seasonal trends)." | |
| ) | |
| st.image(str(pred_img), width='stretch') | |
| if imp_path.exists(): | |
| imp = load_metrics(str(imp_path)) | |
| st.subheader("Feature importance (best tree model)") | |
| with st.expander("What is feature importance?"): | |
| st.markdown( | |
| "Feature importance measures how much each input variable " | |
| "contributes to reducing prediction error across all splits in tree-based models. " | |
| "Higher values mean the feature is more influential for predicting A." | |
| ) | |
| # Bar chart | |
| if _HAS_PLOTLY and len(imp.columns) >= 2: | |
| feat_col = imp.columns[0] | |
| val_col = imp.columns[1] | |
| imp_sorted = imp.sort_values(val_col, ascending=True) | |
| fig_imp = px.bar( | |
| imp_sorted, x=val_col, y=feat_col, orientation="h", | |
| title="Feature importance", | |
| ) | |
| fig_imp.update_layout(yaxis_title="", xaxis_title="Importance", height=400) | |
| with st.expander("How to read this chart"): | |
| st.markdown( | |
| "Each bar shows how much a feature contributes to reducing prediction " | |
| "error across all tree splits. Longer bars = more influential features. " | |
| "**ghi_w_m2** (solar radiation) and time features typically dominate, " | |
| "confirming that light and time-of-day drive most of the variation in A." | |
| ) | |
| st.plotly_chart(fig_imp) | |
| else: | |
| st.dataframe(imp, hide_index=True) | |
| else: | |
| st.info("No prediction metrics found. Run `python scripts/run_pipeline.py` to generate them.") | |
| # --- Time Series Forecasting section --- | |
| st.divider() | |
| st.subheader("Can we predict ahead of time?") | |
| with st.expander("What is this?"): | |
| st.markdown( | |
| "Instead of just estimating current photosynthesis, we also test whether we can " | |
| "**predict it into the future** — from 15 minutes to 1 month ahead. This is " | |
| "important for planning irrigation and shade schedules. The model uses recent " | |
| "photosynthesis history plus weather data to make these forecasts." | |
| ) | |
| ts_comp_path = settings.OUTPUTS_DIR / "ts_horizon_comparison.csv" | |
| ts_season_path = settings.OUTPUTS_DIR / "ts_per_season_metrics.csv" | |
| _HORIZON_ORDER = ["15min", "1hour", "1day", "1week", "1month"] | |
| if ts_comp_path.exists(): | |
| ts_comp = load_metrics(str(ts_comp_path)) | |
| # Split into time-series and cross-sectional rows (season == "all" only) | |
| _season_filter = (ts_comp["season"] == "all") if "season" in ts_comp.columns else pd.Series([True] * len(ts_comp), index=ts_comp.index) | |
| _ts_rows = ts_comp[_season_filter & (ts_comp["approach"] == "time_series")].copy() | |
| _xs_rows = ts_comp[_season_filter & ts_comp["approach"].str.startswith("cross_sectional", na=False)].drop_duplicates("horizon").copy() | |
| # --- Metric cards --- | |
| _ts_15 = _ts_rows[_ts_rows["horizon"] == "15min"] | |
| _best_ts_rmse = float(_ts_15["RMSE"].iloc[0]) if not _ts_15.empty else None | |
| _best_ts_r2 = float(_ts_15["R2"].iloc[0]) if not _ts_15.empty else None | |
| _best_xs_rmse = float(_xs_rows["RMSE"].iloc[0]) if not _xs_rows.empty else None | |
| _best_xs_r2 = float(_xs_rows["R2"].iloc[0]) if not _xs_rows.empty else None | |
| _mc1, _mc2, _mc3 = st.columns(3) | |
| if _best_ts_rmse is not None: | |
| _delta_rmse = f"{_best_ts_rmse - _best_xs_rmse:+.2f} vs cross-sectional" if _best_xs_rmse else None | |
| _mc1.metric( | |
| "TS RMSE @ 15 min", | |
| f"{_best_ts_rmse:.2f} µmol m⁻² s⁻¹", | |
| delta=_delta_rmse, | |
| delta_color="inverse", | |
| help="RMSE of the time-series model at the shortest horizon. Lower is better.", | |
| ) | |
| if _best_ts_r2 is not None: | |
| _delta_r2 = f"{_best_ts_r2 - _best_xs_r2:+.3f} vs cross-sectional" if _best_xs_r2 is not None else None | |
| _mc2.metric( | |
| "TS R² @ 15 min", | |
| f"{_best_ts_r2:.3f}", | |
| delta=_delta_r2, | |
| help="R² of the time-series model at the shortest horizon. 1.0 = perfect.", | |
| ) | |
| if _best_xs_rmse is not None: | |
| _mc3.metric( | |
| "Cross-sectional RMSE", | |
| f"{_best_xs_rmse:.2f} µmol m⁻² s⁻¹", | |
| help="Best cross-sectional model (LinearRegression on IMS features only). Same RMSE at every horizon — no lag information.", | |
| ) | |
| # --- Summary pivot table --- | |
| _ts_tbl = _ts_rows[["horizon", "RMSE", "MAE", "R2"]].rename( | |
| columns={"RMSE": "TS RMSE", "MAE": "TS MAE", "R2": "TS R²"} | |
| ) | |
| _xs_tbl = _xs_rows[["horizon", "RMSE", "R2"]].rename( | |
| columns={"RMSE": "XS RMSE", "R2": "XS R²"} | |
| ) | |
| _summary = _ts_tbl.merge(_xs_tbl, on="horizon", how="left") | |
| _summary["RMSE Δ"] = (_summary["TS RMSE"] - _summary["XS RMSE"]).round(3) | |
| _summary["_ord"] = _summary["horizon"].map({h: i for i, h in enumerate(_HORIZON_ORDER)}) | |
| _summary = _summary.sort_values("_ord").drop(columns=["_ord"]) | |
| for _col in ["TS RMSE", "TS MAE", "TS R²", "XS RMSE", "XS R²"]: | |
| if _col in _summary.columns: | |
| _summary[_col] = _summary[_col].round(3) | |
| st.markdown("**Summary: time-series vs cross-sectional by horizon**") | |
| with st.expander("How to read this table"): | |
| st.markdown( | |
| "Each row is a forecast horizon. **TS** = time-series model (uses lagged A + weather features). " | |
| "**XS** = best cross-sectional model (IMS weather only, no lag memory — same RMSE at every horizon). " | |
| "**RMSE \u0394** = TS RMSE \u2212 XS RMSE: negative means time-series wins, positive means it loses. " | |
| "Time-series should win at short horizons (recent A values are informative) " | |
| "and lose at long horizons (lags are too stale to help)." | |
| ) | |
| st.dataframe(_summary, hide_index=True) | |
| # --- Dual chart: RMSE and R² side by side --- | |
| if _HAS_PLOTLY and not _ts_rows.empty: | |
| _ts_chart = _ts_rows.copy() | |
| _xs_chart = _xs_rows.copy() | |
| _ts_chart["_ord"] = _ts_chart["horizon"].map({h: i for i, h in enumerate(_HORIZON_ORDER)}) | |
| _xs_chart["_ord"] = _xs_chart["horizon"].map({h: i for i, h in enumerate(_HORIZON_ORDER)}) | |
| _ts_chart = _ts_chart.sort_values("_ord") | |
| _xs_chart = _xs_chart.sort_values("_ord") | |
| fig_dual = make_subplots( | |
| rows=1, cols=2, | |
| subplot_titles=[ | |
| "RMSE by horizon (lower = better)", | |
| "R² by horizon (higher = better)", | |
| ], | |
| ) | |
| # RMSE | |
| fig_dual.add_trace(go.Bar( | |
| x=_ts_chart["horizon"].tolist(), y=_ts_chart["RMSE"].tolist(), | |
| name="Time-series", marker_color=_BRAND_GREEN, | |
| text=[f"{v:.2f}" for v in _ts_chart["RMSE"]], textposition="outside", | |
| ), row=1, col=1) | |
| if not _xs_chart.empty: | |
| fig_dual.add_trace(go.Bar( | |
| x=_xs_chart["horizon"].tolist(), y=_xs_chart["RMSE"].tolist(), | |
| name="Cross-sectional", marker_color="#FF8C00", | |
| text=[f"{v:.2f}" for v in _xs_chart["RMSE"]], textposition="outside", | |
| ), row=1, col=1) | |
| # R² | |
| fig_dual.add_trace(go.Bar( | |
| x=_ts_chart["horizon"].tolist(), y=_ts_chart["R2"].tolist(), | |
| name="Time-series", marker_color=_BRAND_GREEN, | |
| text=[f"{v:.3f}" for v in _ts_chart["R2"]], textposition="outside", | |
| showlegend=False, | |
| ), row=1, col=2) | |
| if not _xs_chart.empty: | |
| fig_dual.add_trace(go.Bar( | |
| x=_xs_chart["horizon"].tolist(), y=_xs_chart["R2"].tolist(), | |
| name="Cross-sectional", marker_color="#FF8C00", | |
| text=[f"{v:.3f}" for v in _xs_chart["R2"]], textposition="outside", | |
| showlegend=False, | |
| ), row=1, col=2) | |
| # Baseline reference line on RMSE chart | |
| if _best_xs_rmse is not None: | |
| fig_dual.add_hline( | |
| y=_best_xs_rmse, line_dash="dash", line_color="gray", | |
| annotation_text=f"XS baseline ({_best_xs_rmse:.2f})", | |
| row=1, col=1, | |
| ) | |
| # Zero line on R² chart | |
| fig_dual.add_hline( | |
| y=0, line_dash="dot", line_color="red", | |
| annotation_text="R²=0 (predicting mean)", | |
| row=1, col=2, | |
| ) | |
| fig_dual.update_layout( | |
| height=460, barmode="group", | |
| legend=dict(orientation="h", yanchor="bottom", y=1.05, xanchor="right", x=1), | |
| ) | |
| fig_dual.update_yaxes(title_text="RMSE (\u00b5mol m\u207b\u00b2 s\u207b\u00b9)", row=1, col=1) | |
| fig_dual.update_yaxes(title_text="R\u00b2", row=1, col=2) | |
| with st.expander("How to read these charts"): | |
| st.markdown( | |
| "**Left \u2014 RMSE:** lower = better. Green = time-series (with lag features), " | |
| "orange = cross-sectional (weather only). The dashed line is the cross-sectional " | |
| "baseline \u2014 green bars below it mean lags help at that horizon. " | |
| "The 1-month spike is expected: a 30-day-ahead forecast from recent lags is unreliable.\n\n" | |
| "**Right \u2014 R\u00b2:** higher = better. Above the red dotted line (R\u00b2 > 0) means the " | |
| "model beats a simple mean prediction. Negative R\u00b2 (e.g. 1-month) means the model " | |
| "is worse than the mean." | |
| ) | |
| st.plotly_chart(fig_dual) | |
| # --- Per-season breakdown --- | |
| if ts_season_path.exists(): | |
| ts_season = load_metrics(str(ts_season_path)) | |
| ts_per = ts_season[ts_season["season"] != "all"].copy() | |
| if not ts_per.empty: | |
| st.markdown("**Per-season breakdown**") | |
| _per_cols = [c for c in ["horizon", "season", "RMSE", "MAE", "R2", "n_train", "n_test"] if c in ts_per.columns] | |
| ts_per_disp = ts_per[_per_cols].copy() | |
| ts_per_disp["_ord"] = ts_per_disp["horizon"].map({h: i for i, h in enumerate(_HORIZON_ORDER)}) | |
| ts_per_disp = ts_per_disp.sort_values(["_ord", "season"]).drop(columns=["_ord"]) | |
| for _col in ["RMSE", "MAE", "R2"]: | |
| if _col in ts_per_disp.columns: | |
| ts_per_disp[_col] = ts_per_disp[_col].round(3) | |
| with st.expander("How to read the per-season table"): | |
| st.markdown( | |
| "Each growing season (May\u2013Sep) is evaluated independently \u2014 lags and targets " | |
| "never cross the off-season gap. **n_train / n_test** show the number of 15-min " | |
| "samples used. Year-to-year differences reveal weather variability: a season " | |
| "with steadier radiation has lower RMSE. Missing rows mean not enough data " | |
| "for that horizon in that season (e.g. 1-month needs 30 days of future data)." | |
| ) | |
| st.dataframe(ts_per_disp, hide_index=True) | |
| if _HAS_PLOTLY: | |
| _per_chart = ts_per.copy() | |
| _per_chart["_ord"] = _per_chart["horizon"].map({h: i for i, h in enumerate(_HORIZON_ORDER)}) | |
| _per_chart = _per_chart.sort_values(["_ord", "season"]) | |
| fig_s = px.bar( | |
| _per_chart, x="horizon", y="RMSE", color="season", | |
| barmode="group", | |
| title="RMSE by horizon and season", | |
| labels={"RMSE": "RMSE (\u00b5mol m\u207b\u00b2 s\u207b\u00b9)", "horizon": "Horizon"}, | |
| text="RMSE", | |
| ) | |
| fig_s.update_traces(texttemplate="%{text:.2f}", textposition="outside") | |
| fig_s.update_layout(height=400) | |
| st.plotly_chart(fig_s) | |
| st.download_button( | |
| "Download horizon comparison CSV", | |
| ts_comp.to_csv(index=False), | |
| file_name="ts_horizon_comparison.csv", | |
| mime="text/csv", | |
| ) | |
| else: | |
| st.info("No time-series results found. Run `python scripts/run_pipeline.py` to generate them.") | |
| # ---- IMS Cache Coverage Panel ------------------------------------------ | |
| st.divider() | |
| st.subheader("IMS Weather Data Coverage") | |
| with st.spinner("Loading IMS cache coverage…"): | |
| try: | |
| from src.ims_client import IMSClient as _IMSClient | |
| _ims = _IMSClient() | |
| _df_ims = _ims.load_cached() | |
| if _df_ims.empty: | |
| st.info("No IMS data in cache. Use the IMS fetch controls to populate it.") | |
| else: | |
| _ts_col = "timestamp_utc" if "timestamp_utc" in _df_ims.columns else _df_ims.columns[0] | |
| _ts = pd.to_datetime(_df_ims[_ts_col], utc=True) | |
| _first = _ts.min() | |
| _last = _ts.max() | |
| _n_days = (_last - _first).days + 1 | |
| _now_utc = pd.Timestamp.now(tz="UTC") | |
| _age_min = float((_now_utc - _last).total_seconds() / 60.0) | |
| _age_str = ( | |
| f"{_age_min:.0f} min" if _age_min < 60 | |
| else f"{_age_min / 60:.1f} h" | |
| ) | |
| _cov1, _cov2, _cov3, _cov4 = st.columns(4) | |
| _cov1.metric("First record", _first.strftime("%Y-%m-%d %H:%M UTC")) | |
| _cov2.metric("Last record", _last.strftime("%Y-%m-%d %H:%M UTC")) | |
| _cov3.metric("Days of coverage", f"{_n_days} d") | |
| _cov4.metric("Age of latest record", _age_str) | |
| except Exception as _cov_exc: | |
| st.warning(f"Could not load IMS cache: {_cov_exc}") | |
| # ---- Day-Ahead Forecasting (Chronos-2) -------------------------------- | |
| st.divider() | |
| st.subheader("Day-Ahead Forecasting with Chronos-2") | |
| st.markdown( | |
| "**Chronos-2** is a pre-trained time-series foundation model (Amazon, 120 M params) " | |
| "that forecasts photosynthesis rate **24 hours ahead** using weather covariates. " | |
| "Because it is a *foundation model*, it requires **no task-specific training** — " | |
| "the model generalises from its pre-training corpus to our vineyard signal." | |
| ) | |
| with st.expander("What is Chronos-2?"): | |
| st.markdown(""" | |
| **Foundation model approach** — Chronos-2 is pre-trained on a massive corpus of | |
| open time-series data, then applied *zero-shot* to our vineyard A series. We use | |
| four covariate modes (walk-forward, daytime-only evaluation): | |
| | Mode | Covariates | | |
| |------|-----------| | |
| | `none` | Univariate (A only) | | |
| | `ims` | IMS station 43: GHI, air T, RH, wind | | |
| | `sensor` | On-site: PAR, VPD, T_leaf, CO₂ | | |
| | `all` | IMS + sensor (past); IMS as future covariates | | |
| **Input pipeline** — Data is merged (labels + IMS + sensors), resampled to a | |
| regular 15-min grid, and engineered time/stress columns are available in the | |
| pipeline; the benchmark uses IMS + sensor for the best zero-shot trade-off. | |
| """) | |
| chronos_path = settings.OUTPUTS_DIR / "chronos_benchmark.csv" | |
| chronos_bench = load_metrics(chronos_path) if chronos_path.exists() else pd.DataFrame() | |
| if not chronos_bench.empty: | |
| # --- Metric cards --- (zero-shot = any row that is not LoRA or ML baseline) | |
| zs_mask = ~chronos_bench["mode"].str.contains("lora|ML baseline", case=False, na=False) | |
| zs_rows = chronos_bench.loc[zs_mask].copy() | |
| best_mae_row = zs_rows.loc[zs_rows["MAE"].idxmin()] if not zs_rows.empty else None | |
| ml_row = chronos_bench[chronos_bench["mode"].str.contains("ML baseline", case=False, na=False)] | |
| ml_mae = float(ml_row["MAE"].iloc[0]) if not ml_row.empty else 2.7 | |
| c1, c2, c3 = st.columns(3) | |
| if best_mae_row is not None: | |
| mode_label = best_mae_row["mode"].split("/")[-1].strip() if "/" in str(best_mae_row["mode"]) else best_mae_row["mode"] | |
| c1.metric("Best Zero-Shot MAE", f"{best_mae_row['MAE']:.2f} µmol m⁻² s⁻¹", | |
| delta=f"{mode_label} covariates") | |
| best_r2 = best_mae_row.get("R2") or best_mae_row.get("R²") | |
| best_r2 = best_r2 if pd.notna(best_r2) else 0.52 | |
| c2.metric("R²", f"{float(best_r2):.2f}") | |
| c3.metric("ML Baseline MAE", f"{ml_mae:.1f} µmol m⁻² s⁻¹", | |
| delta=f"{((ml_mae - (best_mae_row['MAE'] if best_mae_row is not None else ml_mae)) / ml_mae * 100):.0f}% improvement", | |
| delta_color="normal") | |
| # --- Covariate ablation table (zero-shot only) --- | |
| st.markdown("##### Covariate Ablation (Zero-Shot)") | |
| if not zs_rows.empty: | |
| r2_col = "R2" if "R2" in zs_rows.columns else "R²" | |
| display_zs = zs_rows[["mode", "MAE", "RMSE", r2_col]].copy() | |
| display_zs.columns = ["Mode", "MAE", "RMSE", "R²"] | |
| st.dataframe(display_zs.style.format({"MAE": "{:.4f}", "RMSE": "{:.4f}", "R²": "{:.4f}"}), | |
| hide_index=True) | |
| # --- Per-month LoRA table --- | |
| lora_rows = chronos_bench[chronos_bench["mode"].str.contains("lora", case=False, na=False)].copy() | |
| if not lora_rows.empty: | |
| st.markdown("##### Per-Month LoRA Fine-Tuning") | |
| lora_rows["Month"] = lora_rows["mode"].str.extract(r"(\w+)$") | |
| display_lora = lora_rows[["Month", "MAE", "R2"]].copy() | |
| display_lora.columns = ["Month", "MAE", "R²"] | |
| st.dataframe(display_lora.style.format({"MAE": "{:.3f}", "R²": "{:.3f}"}), | |
| hide_index=True) | |
| # --- Bar chart: MAE by covariate mode --- | |
| if _HAS_PLOTLY and not zs_rows.empty: | |
| fig_chr = px.bar( | |
| zs_rows, x="mode", y="MAE", | |
| title="Zero-Shot MAE by Covariate Mode", | |
| labels={"MAE": "MAE (µmol m⁻² s⁻¹)", "mode": "Covariate Mode"}, | |
| text="MAE", | |
| color_discrete_sequence=[_BRAND_GREEN], | |
| ) | |
| fig_chr.update_traces(texttemplate="%{text:.2f}", textposition="outside") | |
| fig_chr.add_hline(y=ml_mae, line_dash="dash", line_color="red", | |
| annotation_text=f"ML baseline ({ml_mae})") | |
| fig_chr.update_layout(height=400, showlegend=False) | |
| st.plotly_chart(fig_chr) | |
| # --- Forecast sample image --- | |
| forecast_img = settings.OUTPUTS_DIR / "chronos_forecast_sample.png" | |
| if forecast_img.exists(): | |
| with st.expander("Sample Forecast with Confidence Bands", expanded=True): | |
| st.image(str(forecast_img), width='stretch') | |
| st.caption( | |
| "Shaded regions show 80% and 95% prediction intervals from " | |
| "Chronos-2's probabilistic output. Unlike point-estimate ML models, " | |
| "the foundation model quantifies forecast uncertainty natively." | |
| ) | |
| st.info( | |
| "**Key insight:** Chronos-2 zero-shot with IMS + sensor covariates achieves " | |
| "MAE ~3.9 µmol m⁻² s⁻¹ (R² ~0.52) in walk-forward evaluation, with **probabilistic** " | |
| "forecasts (10–90% quantiles). The pipeline was revisited with extended IMS (tdmax/tdmin) " | |
| "and time/stress features; the current best uses the standard IMS + on-site sensor set." | |
| ) | |
| else: | |
| st.info("No Chronos-2 benchmark results found. See `outputs/chronos_benchmark.csv`.") | |
| # ---- Intelligent Model Routing (Gemini) -------------------------------- | |
| st.divider() | |
| st.subheader("Intelligent Model Routing (Gemini)") | |
| st.markdown( | |
| "An LLM-based routing agent classifies real-time vineyard conditions and " | |
| "selects the most appropriate photosynthesis model: **FvCB** (Farquhar) for " | |
| "standard conditions or **ML ensemble** for non-linear heat/water stress." | |
| ) | |
| routing_scenarios = pd.DataFrame({ | |
| "Scenario": [ | |
| "Cool morning (T=22°C, PAR=800)", | |
| "Hot afternoon (T=38°C, VPD=3.2 kPa)", | |
| "Moderate midday (T=29°C, well-watered)", | |
| ], | |
| "Routed Model": ["FvCB (Farquhar)", "ML Ensemble", "ML Ensemble"], | |
| "Rationale": [ | |
| "Below 30°C transition — RuBP-limited, FvCB is reliable", | |
| "Above stress threshold — non-linear response, ML captures better", | |
| "Near transition — ML provides safer prediction margin", | |
| ], | |
| }) | |
| st.dataframe(routing_scenarios, hide_index=True) | |
| with st.expander("How does routing work?"): | |
| st.markdown(""" | |
| **System prompt** — The routing agent receives a structured system prompt | |
| describing the two available models, their strengths, and decision criteria | |
| based on temperature, VPD, and radiation thresholds. | |
| **Gemini 2.5 Flash** — We use Google's Gemini 2.5 Flash for low-latency | |
| classification (~200 ms per call). The model returns a structured JSON | |
| response with `model_choice` and `reasoning` fields. | |
| **Fallback logic** — If the LLM call fails or times out, the system defaults | |
| to the ML ensemble (the safer, more general option). | |
| **Architecture:** | |
| ``` | |
| Telemetry (T, VPD, PAR, SWC) | |
| │ | |
| ▼ | |
| Gemini 2.5 Flash | |
| (routing classifier) | |
| │ | |
| ┌────┴────┐ | |
| ▼ ▼ | |
| FvCB ML Ensemble | |
| (standard) (stress) | |
| │ │ | |
| └────┬────┘ | |
| ▼ | |
| A prediction (µmol m⁻² s⁻¹) | |
| ``` | |
| """) | |
| # ---- Day-Ahead Stress Advisory (Gemini) --------------------------------- | |
| st.divider() | |
| st.subheader("Day-Ahead Stress Advisory (Gemini)") | |
| st.markdown( | |
| "Gemini analyzes the day-ahead weather forecast through **Semillon vine biology rules** " | |
| "to produce a structured stress profile, energy budget recommendations, and model routing " | |
| "preferences. This advisory feeds into the future day-ahead planner." | |
| ) | |
| try: | |
| from src.day_ahead_advisor import DayAheadAdvisor | |
| # Load last available IMS day from cache as demo forecast | |
| _ims_cache_path = Path("Data/ims/ims_merged_15min.csv") | |
| if _ims_cache_path.exists(): | |
| _adv_df = pd.read_csv(_ims_cache_path, parse_dates=True) | |
| for _tc in ["timestamp_utc", "datetime", "time", "timestamp"]: | |
| if _tc in _adv_df.columns: | |
| _adv_df.index = pd.to_datetime(_adv_df[_tc]) | |
| break | |
| if isinstance(_adv_df.index, pd.DatetimeIndex): | |
| _last_date = _adv_df.index.date[-1] | |
| _adv_day = _adv_df[_adv_df.index.date == _last_date] | |
| _date_str = str(_last_date) | |
| else: | |
| _adv_day = _adv_df.tail(96) | |
| _date_str = "unknown" | |
| _advisor = DayAheadAdvisor(verbose=False) | |
| _report = _advisor.advise( | |
| date=_date_str, | |
| weather_forecast=_adv_day, | |
| phenological_stage="veraison", | |
| remaining_weekly_budget_kWh=15.0, | |
| remaining_monthly_budget_kWh=50.0, | |
| ) | |
| # Metric cards | |
| _ac1, _ac2, _ac3, _ac4 = st.columns(4) | |
| _ac1.metric("Peak Severity", | |
| _report.stress_profile.peak_stress_severity.capitalize()) | |
| _ac2.metric("Peak Stress Hour", | |
| f"{_report.stress_profile.peak_stress_hour}:00") | |
| _ac3.metric("Budget Rec.", | |
| f"{_report.budget_recommendation.daily_budget_fraction:.0%}") | |
| _ac4.metric("Rubisco-Limited Hours", | |
| _report.stress_profile.rubisco_limited_hours) | |
| # Summary | |
| st.info(_report.stress_profile.summary) | |
| # Full JSON report | |
| with st.expander("Full Advisory Report (JSON)"): | |
| st.json(DayAheadAdvisor.report_to_dict(_report)) | |
| else: | |
| st.info("No IMS data found at Data/ims/ims_merged_15min.csv. " | |
| "Run `python scripts/download_ims_data.py` to fetch weather data.") | |
| except Exception as _adv_exc: | |
| st.warning(f"Day-Ahead Advisory unavailable: {_adv_exc}") | |
| # --------------------------------------------------------------------------- | |
| # --------------------------------------------------------------------------- | |
| # Tab — Photosynthesis & Data (combines Farquhar Model, Model Validation, Data Explorer) | |
| # --------------------------------------------------------------------------- | |