""" 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) # ---------------------------------------------------------------------------