Download ui/tab_overview.py from SolarWine/api: direct link, hf CLI and curl.
- Browser
- Download file 24 kB
-
https://huggingface.co/spaces/SolarWine/api/resolve/main/ui/tab_overview.py
- Command line
-
hf download hf://spaces/SolarWine/api/ui/tab_overview.py
-
curl -L -o tab_overview.py https://huggingface.co/spaces/SolarWine/api/resolve/main/ui/tab_overview.py
24 kB
| """ | |
| Overview tab: presentation and key takeaways for the SolarWine app. | |
| """ | |
| from __future__ import annotations | |
| import pandas as pd | |
| import streamlit as st | |
| from config import settings | |
| from ui.bootstrap import ( | |
| check_ai_health, | |
| ensure_ims_refresh_background, | |
| load_labels, | |
| load_metrics, | |
| ) | |
| _ASSETS = settings.PROJECT_ROOT / "assets" | |
| def render_tab_overview() -> None: | |
| _ai_status = check_ai_health() | |
| _ims_status = ensure_ims_refresh_background() | |
| status_cols = st.columns(2) | |
| with status_cols[0]: | |
| if _ai_status.get("ok"): | |
| st.caption("AI service status: healthy (Gemini reachable).") | |
| else: | |
| st.warning( | |
| "AI service status: unavailable. " | |
| "Gemini calls may fall back to biology-only answers or cached data. " | |
| "Check your GOOGLE_API_KEY or network access." | |
| ) | |
| if _ai_status.get("error"): | |
| st.caption(f"AI health check error: `{_ai_status['error']}`") | |
| with status_cols[1]: | |
| if _ims_status.get("running"): | |
| st.caption("IMS cache status: refreshing recent weather data in the background…") | |
| elif _ims_status.get("ok"): | |
| st.caption("IMS cache status: refreshed with recent weather data.") | |
| else: | |
| st.warning( | |
| "IMS cache status: not up to date. " | |
| "Latest weather in the app may be stale until IMS is fixed." | |
| ) | |
| if _ims_status.get("error"): | |
| st.caption(f"IMS refresh error: `{_ims_status['error']}`") | |
| st.markdown("## 1. SolarWine — Smart Shading for Agrivoltaic Vineyards") | |
| st.markdown( | |
| "**Site:** Sde Boker research station, Negev desert, Israel \n" | |
| "**Crop:** *Vitis vinifera* cv. Semillon grapevine \n" | |
| "**Challenge:** Solar panels and grapevines compete for the same sunlight. " | |
| "How do we split it so both thrive?" | |
| ) | |
| _vineyard_img = _ASSETS / "vineyard_panels.png" | |
| _closeup_img = _ASSETS / "vineyard_closeup.png" | |
| if _vineyard_img.exists() or _closeup_img.exists(): | |
| _p_img_l, _p_img_r = st.columns(2) | |
| if _vineyard_img.exists(): | |
| with _p_img_l: | |
| st.image(str(_vineyard_img), width='stretch', | |
| caption="Solar trackers above grapevine rows") | |
| if _closeup_img.exists(): | |
| with _p_img_r: | |
| st.image(str(_closeup_img), width='stretch', | |
| caption="Grapevines under tilted solar panels") | |
| st.markdown( | |
| "**Why photosynthesis?** Our working hypothesis is simple: " | |
| "a vine that photosynthesizes well produces good wine. Photosynthesis " | |
| "drives sugar accumulation, flavor-compound synthesis, and canopy health " | |
| "— so if we can keep A (net carbon assimilation) high throughout the " | |
| "growing season, grape quality follows. Everything in this system is " | |
| "built around measuring, predicting, and protecting photosynthesis." | |
| ) | |
| st.markdown( | |
| "The Negev provides **far more radiation** than a grapevine can use — " | |
| "midday PAR routinely exceeds 2000 µmol/m²/s, but photosynthesis " | |
| "saturates well below that. The surplus can be harvested as electricity " | |
| "without hurting the vine." | |
| ) | |
| st.markdown( | |
| "But first we need to *measure* how much photosynthesis the vine actually " | |
| "performs — that's what the Farquhar model does." | |
| ) | |
| with st.expander("What is agrivoltaics? What is PAR?"): | |
| st.markdown( | |
| "**Agrivoltaics** is the dual use of land for both agriculture and solar " | |
| "energy — panels are mounted above the crops so both share the same field.\n\n" | |
| "**PAR (Photosynthetically Active Radiation)** is the slice of sunlight " | |
| "(wavelengths 400–700 nm) that plants actually use for photosynthesis, " | |
| "measured in µmol photons/m²/s.\n\n" | |
| "**Light saturation** means that beyond a certain PAR threshold, more " | |
| "light does *not* increase photosynthesis — the vine's internal " | |
| "biochemistry becomes the bottleneck, not the light supply.\n\n" | |
| "**Why Sde Boker?** The Negev desert delivers extreme radiation, well " | |
| "above the vine's saturation point, making it an ideal site to demonstrate " | |
| "that surplus light can be redirected to electricity without harming the crop." | |
| ) | |
| st.divider() | |
| st.markdown("## 2. Two-Stage Pipeline") | |
| st.markdown( | |
| "We built a two-stage system to model and then predict vine photosynthesis:" | |
| ) | |
| _p_s1, _p_arrow1, _p_s2, _p_arrow2, _p_s3 = st.columns([3, 1, 3, 1, 3]) | |
| with _p_s1: | |
| st.markdown( | |
| "### Stage 1: Measure\n" | |
| "**6 on-site sensors** (PAR, leaf temp, air temp, CO₂, VPD, humidity) " | |
| "→ Farquhar FvCB mechanistic model → **photosynthesis rate A** " | |
| "(µmol CO₂ m⁻² s⁻¹)" | |
| ) | |
| with _p_arrow1: | |
| st.markdown("<div style='text-align:center; font-size:2rem; padding-top:2rem'>→</div>", | |
| unsafe_allow_html=True) | |
| with _p_s2: | |
| st.markdown( | |
| "### Stage 2: Predict\n" | |
| "**On-site sensors + IMS weather** (station 43 — temperature, GHI, " | |
| "humidity, wind, rain) → ML regressors (RF, GBR, XGBoost) → " | |
| "**forecast A ahead of time**" | |
| ) | |
| with _p_arrow2: | |
| st.markdown("<div style='text-align:center; font-size:2rem; padding-top:2rem'>→</div>", | |
| unsafe_allow_html=True) | |
| with _p_s3: | |
| st.markdown( | |
| "### Goal\n" | |
| "Predict tomorrow's photosynthesis so the **panel-tilt optimizer** " | |
| "can plan tracker angles a day ahead — shading only when the vine " | |
| "actually needs protection." | |
| ) | |
| st.info( | |
| "**CO₂ correction:** On-site CO₂ sensor reads ~600 ppm (known warm bias). " | |
| "A ×0.7 correction factor brings values to ~420 ppm, consistent with " | |
| "ambient atmospheric levels." | |
| ) | |
| with st.expander("Key terms: Farquhar model, ML regressors, IMS"): | |
| st.markdown( | |
| "**Farquhar FvCB model** (1980) is the standard physics-based model that " | |
| "predicts leaf photosynthesis from light, temperature, and CO₂ — think of " | |
| "it as a formula that says *'given this much light and heat, the leaf fixes " | |
| "this much carbon.'*\n\n" | |
| "**ML regressors** are machine-learning algorithms that learn patterns " | |
| "from data:\n" | |
| "- **RF (Random Forest):** many decision trees vote together\n" | |
| "- **GBR (Gradient Boosted Regression):** trees are added one at a time, " | |
| "each correcting the previous errors\n" | |
| "- **XGBoost:** an optimised variant of gradient boosting\n\n" | |
| "**IMS** — Israel Meteorological Service, providing free public weather " | |
| "station data.\n\n" | |
| "**VPD (Vapor Pressure Deficit)** measures how dry the air is. High VPD " | |
| "makes the vine close its stomata (leaf pores) to conserve water, which " | |
| "reduces CO₂ intake and slows photosynthesis.\n\n" | |
| "**Why two stages?** Stage 1 computes A from current sensor readings " | |
| "(what is happening *now*); Stage 2 learns to *forecast* A ahead of time " | |
| "so the tracker optimizer can plan tomorrow's panel angles today." | |
| ) | |
| st.divider() | |
| st.markdown("## 3. Stage 1 — Farquhar Mechanistic Model") | |
| st.markdown( | |
| "The Farquhar et al. (1980) model with Greer & Weedon (2012) grapevine " | |
| "parameters computes **A = min(Ac, Aj) − Rd** at each 15-min interval " | |
| "during the growing season (May–Sep, daytime PAR > 50)." | |
| ) | |
| _p_labels_path = settings.PROCESSED_DIR / "stage1_labels.csv" | |
| _p_val_img = settings.OUTPUTS_DIR / "stage1_validation.png" | |
| if _p_labels_path.exists(): | |
| _p_labels = load_labels(str(_p_labels_path)) | |
| _p_labels.index = pd.to_datetime(_p_labels.index, utc=True) | |
| _pc1, _pc2, _pc3, _pc4 = st.columns(4) | |
| _pc1.metric("Observations", f"{len(_p_labels):,}") | |
| _pc2.metric("Mean A", f"{_p_labels.iloc[:, 0].mean():.1f} µmol m⁻² s⁻¹") | |
| _pc3.metric("Date from", _p_labels.index.min().strftime("%Y-%m-%d")) | |
| _pc4.metric("Date to", _p_labels.index.max().strftime("%Y-%m-%d")) | |
| else: | |
| st.warning("Stage 1 labels not found — run the pipeline first.") | |
| if _p_val_img.exists(): | |
| st.image(str(_p_val_img), width='stretch', | |
| caption="Top: Diurnal A pattern (bell curve peaking mid-morning). " | |
| "Bottom: A vs PAR (light-response curve with saturation).") | |
| st.success( | |
| "**Key insight:** The model produces physiologically plausible A values — " | |
| "a clear diurnal bell curve and the expected light-saturation response." | |
| ) | |
| st.markdown( | |
| "With reliable A measurements in hand, the next step is *predicting* A " | |
| "from weather data so the system can plan ahead." | |
| ) | |
| with st.expander("Reading the results: what is A? What do the plots show?"): | |
| st.markdown( | |
| "**A (net photosynthesis rate)** is measured in µmol CO₂ per m² of leaf " | |
| "per second — higher means the vine is growing faster. Healthy grapevines " | |
| "typically range from 5 to 20.\n\n" | |
| "**Diurnal bell curve:** photosynthesis peaks mid-morning when both light " | |
| "and temperature are favourable, then drops in the afternoon heat.\n\n" | |
| "**Light-response curve:** A increases with PAR up to a point, then " | |
| "flattens (saturates) — this proves the vine cannot use all of the " | |
| "Negev's intense sunlight.\n\n" | |
| "**\"Physiologically plausible\"** means the model reproduces the patterns " | |
| "that plant biologists expect from real grapevines — it behaves like a " | |
| "real vine, not just a curve fit." | |
| ) | |
| st.divider() | |
| st.markdown("## 4. Stage 2 — ML Prediction from Weather Data") | |
| _p_metrics_path = settings.OUTPUTS_DIR / "stage2_metrics.csv" | |
| _p_pred_img = settings.OUTPUTS_DIR / "stage2_predictions.png" | |
| _p_ts_path = settings.OUTPUTS_DIR / "ts_horizon_comparison.csv" | |
| if _p_metrics_path.exists(): | |
| _p_metrics = load_metrics(str(_p_metrics_path)) | |
| st.markdown("#### Cross-sectional model comparison") | |
| st.dataframe(_p_metrics, hide_index=True) | |
| if "R2" in _p_metrics.columns: | |
| _p_best = _p_metrics.loc[_p_metrics["R2"].idxmax()] | |
| st.markdown( | |
| f"**Best model:** {_p_best['model']} — " | |
| f"R² = {_p_best['R2']:.3f}, RMSE = {_p_best['RMSE']:.2f} µmol m⁻² s⁻¹. " | |
| f"Explains **{_p_best['R2']*100:.0f}%** of variance in A from weather data." | |
| ) | |
| else: | |
| st.warning("Stage 2 metrics not found.") | |
| if _p_pred_img.exists(): | |
| st.image(str(_p_pred_img), width='stretch', | |
| caption="Left: Predicted vs approximate A (scatter). " | |
| "Right: Time-series overlay showing temporal patterns.") | |
| if _p_ts_path.exists(): | |
| _p_ts = load_metrics(str(_p_ts_path)) | |
| st.markdown("#### Time-series forecasting (lagged features)") | |
| _p_ts_all = _p_ts[_p_ts["season"] == "all"] if "season" in _p_ts.columns else _p_ts | |
| _p_ts_ts = _p_ts_all[_p_ts_all["approach"] == "time_series"].copy() | |
| if not _p_ts_ts.empty: | |
| _p_ts_disp = _p_ts_ts[["horizon", "RMSE", "MAE", "R2"]].copy() | |
| for _c in ["RMSE", "MAE", "R2"]: | |
| _p_ts_disp[_c] = _p_ts_disp[_c].round(3) | |
| st.dataframe(_p_ts_disp, hide_index=True) | |
| _p_best_ts = _p_ts_ts.loc[_p_ts_ts["R2"].idxmax()] | |
| st.markdown( | |
| f"**Best horizon:** {_p_best_ts['horizon']} — " | |
| f"R² = {_p_best_ts['R2']:.3f}, RMSE = {_p_best_ts['RMSE']:.2f}. " | |
| "Adding recent A history dramatically improves short-term predictions." | |
| ) | |
| with st.expander("Understanding the metrics: R², RMSE, cross-sectional vs time-series"): | |
| st.markdown( | |
| "**R² (R-squared):** the proportion of variance explained by the model. " | |
| "R² = 0.87 means the model explains 87% of the variation in A — close to " | |
| "1.0 is excellent, 0.0 means no better than guessing the average.\n\n" | |
| "**RMSE (Root Mean Squared Error):** the average prediction error in the " | |
| "same units as A (µmol/m²/s). Lower is better. It penalises large errors " | |
| "more than small ones.\n\n" | |
| "**Cross-sectional model:** predicts A from the weather at the *same* " | |
| "moment — no memory of what happened before.\n\n" | |
| "**Time-series model:** also uses recent past values (lags) — " | |
| "*\"what was A 15 minutes ago?\"* This helps because photosynthesis changes " | |
| "smoothly over time.\n\n" | |
| "**Why short horizons are better:** recent A values are very informative; " | |
| "predicting 15 minutes ahead is much easier than predicting a month ahead." | |
| ) | |
| st.divider() | |
| st.markdown("## 5. ML Strategy — Four Prediction Layers") | |
| st.markdown( | |
| "Predicting photosynthesis is not a single-model problem. We stack four " | |
| "complementary approaches, each adding a capability the previous one lacks:" | |
| ) | |
| _ml_cols = st.columns(4) | |
| with _ml_cols[0]: | |
| st.markdown( | |
| "### Layer 1\n" | |
| "**Cross-sectional ML**\n\n" | |
| "RF, GBR, XGBoost trained on IMS weather features at the *same* time step. " | |
| "No memory of the past — purely \"given today's weather, what is A right now?\" " | |
| "Best R² ≈ 0.80." | |
| ) | |
| with _ml_cols[1]: | |
| st.markdown( | |
| "### Layer 2\n" | |
| "**Time-series forecasting**\n\n" | |
| "Adds lagged A and rolling weather windows (15 min – 3 h) as features. " | |
| "A separate model per horizon (15-min to 1-month). " | |
| "Best R² ≈ 0.87 at 15-min; degrades at longer horizons." | |
| ) | |
| with _ml_cols[2]: | |
| st.markdown( | |
| "### Layer 3\n" | |
| "**Chronos-2 foundation model**\n\n" | |
| "Amazon's pre-trained 120M-parameter time-series model. Zero-shot " | |
| "day-ahead forecast (24 h / 96 steps) with probabilistic confidence " | |
| "bands — no task-specific training needed. Optional LoRA fine-tuning." | |
| ) | |
| with _ml_cols[3]: | |
| st.markdown( | |
| "### Layer 4\n" | |
| "**LLM orchestration (Gemini)**\n\n" | |
| "Two roles: (a) *Data engineer* — LLM-generated anomaly thresholds and " | |
| "stress features; (b) *Routing agent* — classifies real-time conditions " | |
| "and picks FvCB or ML ensemble per 15-min slot, plus day-ahead advisory." | |
| ) | |
| st.markdown( | |
| "Each layer feeds the next: cross-sectional models establish a baseline, " | |
| "time-series models exploit temporal continuity, Chronos-2 extends the " | |
| "horizon to 24 hours with uncertainty quantification, and Gemini ties " | |
| "everything together — cleaning the data, selecting the right model for " | |
| "each moment, and producing actionable day-ahead shading recommendations." | |
| ) | |
| st.markdown( | |
| "Predictions alone aren't enough — we also need to understand exactly " | |
| "how panel shadows affect each part of the canopy." | |
| ) | |
| with st.expander("Why four layers instead of one best model?"): | |
| st.markdown( | |
| "**No single model wins everywhere.** Cross-sectional ML is robust but " | |
| "blind to recent trends. Time-series models excel at short horizons but " | |
| "degrade beyond a few hours. Chronos-2 handles day-ahead with built-in " | |
| "uncertainty but needs a sanity check. The Gemini routing agent acts as " | |
| "an intelligent dispatcher:\n\n" | |
| "- **Cool morning, T < 30 °C →** route to Farquhar (mechanistic model " | |
| "is most accurate in the RuBP-limited regime)\n" | |
| "- **Hot afternoon, T > 30 °C →** route to ML ensemble (better at " | |
| "capturing stress-induced non-linearities)\n" | |
| "- **Day-ahead planning →** Chronos-2 probabilistic forecast, cross-checked " | |
| "against vine biology rules (no shade before 10:00, no shade in May, " | |
| "5% annual energy ceiling)\n\n" | |
| "**Foundation model (Chronos-2):** a large neural network pre-trained on " | |
| "millions of time series from many domains. It learns general patterns " | |
| "(trends, seasonality, noise) and can forecast new series *zero-shot* — " | |
| "without any vineyard-specific training.\n\n" | |
| "**LoRA fine-tuning:** Low-Rank Adaptation — a lightweight method to " | |
| "specialise the foundation model on our vineyard data using only ~500 " | |
| "training steps, without modifying the original weights.\n\n" | |
| "**LLM orchestration:** using a large language model (Gemini) not for " | |
| "chat, but as a reasoning engine that reads sensor statistics, applies " | |
| "domain rules, and outputs structured decisions (thresholds, routing " | |
| "choices, shading schedules)." | |
| ) | |
| st.divider() | |
| st.markdown("## 6. Shadow Model & Shading Analysis") | |
| st.markdown( | |
| "A 3D ray-tracing shadow model computes where panel shadows fall on a " | |
| "VSP-trained canopy (1.2 m tall, 3 vertical zones). Combined with the " | |
| "Farquhar model, this shows how much photosynthesis the vine retains " | |
| "under panels. At midsummer noon the vine retains the vast majority of " | |
| "its photosynthesis under the panel — light saturation means halving PAR " | |
| "barely affects A, so the surplus can be harvested as electricity." | |
| ) | |
| st.markdown( | |
| "This shadow model becomes the physics engine inside the tracker " | |
| "controller — it evaluates candidate tilt angles and their effect on " | |
| "each canopy zone in real time." | |
| ) | |
| st.info( | |
| "See the **Shading Simulator** tab for the interactive 3D shadow viewer " | |
| "with date/hour controls, PAR and photosynthesis heatmaps, and zone-level analysis." | |
| ) | |
| with st.expander("How the shadow model works"): | |
| st.markdown( | |
| "**VSP trellis (Vertical Shoot Positioning):** the standard vineyard " | |
| "training system where shoots grow upward in a flat plane, creating a wall " | |
| "of leaves roughly 1.2 m tall.\n\n" | |
| "**3 vertical zones:** the canopy is divided into bottom (trunk/cordons), " | |
| "middle (fruiting zone — where the grapes hang), and top (main leaf area). " | |
| "Each zone has different leaf density and economic importance.\n\n" | |
| "**Ray-tracing:** the model tracks the sun's position, calculates where " | |
| "the panel casts a shadow, and determines which parts of the canopy are " | |
| "shaded versus sunlit at each time step.\n\n" | |
| "**Why the vine barely notices the panel:** at midday, even the shaded " | |
| "parts receive enough scattered (diffuse) light plus direct light from " | |
| "unshaded portions to saturate photosynthesis." | |
| ) | |
| st.divider() | |
| st.markdown("## 7. Tracker Control & Optimization") | |
| st.markdown( | |
| "**Default = full astronomical tracking** (maximum energy generation). " | |
| "Anti-tracking is a rare, minimum-dose intervention ONLY when the fruiting " | |
| "zone faces measurable heat/radiation risk. The user sets a hard energy " | |
| "sacrifice ceiling (e.g. 5%/year) — the system guarantees it is never exceeded." | |
| ) | |
| _tc_cols = st.columns(4) | |
| with _tc_cols[0]: | |
| st.markdown( | |
| "### Budget Hierarchy\n" | |
| "User sets an annual ceiling (e.g. 5%). Distributed " | |
| "Year → Month → Week → Day → 15-min slot. " | |
| "July–Aug get ~85% of the budget. Unused slots roll forward." | |
| ) | |
| with _tc_cols[1]: | |
| st.markdown( | |
| "### Intervention Gate\n" | |
| "Hard pass/fail filter. **No-shade windows:** before 10:00, May, " | |
| "T < 28 °C, CWSI < 0.3. Eligible only when ALL pass: " | |
| "T ≥ 30 °C, CWSI ≥ 0.4, GHI ≥ 500, FvCB confirms shading helps." | |
| ) | |
| with _tc_cols[2]: | |
| st.markdown( | |
| "### Minimum Dose\n" | |
| "Scan offsets [3°, 5°, 8°, 10°, 15°, 20°] smallest-first. " | |
| "Select first that brings fruiting-zone PAR < 400 µmol while top " | |
| "canopy > 70% sunlit, within budget." | |
| ) | |
| with _tc_cols[3]: | |
| st.markdown( | |
| "### Day-Ahead DP\n" | |
| "Discrete Dynamic Programming at sunrise: optimize 96-slot trajectory. " | |
| "Utility = energy value + crop value − movement cost, subject to " | |
| "daily budget. Phenological weighting (1.5× veraison)." | |
| ) | |
| st.markdown( | |
| "**Typical day:** full tracking 05:00–11:00 → minimum-dose shading " | |
| "~3–8° from 11:00–15:30 → full tracking by 16:00. Over a season: " | |
| "~92% full tracking, ~8% shading, sacrifice ≤ 5%." | |
| ) | |
| st.info( | |
| "This control system is the next implementation phase. The measurement, " | |
| "prediction, and shadow modules above are its foundation. " | |
| "Manual **LiCor gas-exchange measurements** (LI-6400/6800) will serve as " | |
| "ground truth for calibrating model parameters and fine-tuning the ML pipeline." | |
| ) | |
| with st.expander("How does the day-ahead optimizer work?"): | |
| st.markdown( | |
| "**DP in plain English:** plan the best tilt angle for each 15-min slot, " | |
| "knowing that each choice affects how much budget remains for later slots.\n\n" | |
| "**Utility function:** balances electricity revenue vs crop protection, and " | |
| "penalises motor movement (each tilt change costs energy and wear).\n\n" | |
| "**Phenological weighting:** crop value varies by growth stage — 1.5× during " | |
| "veraison (berry ripening), 0.5× post-harvest — so the system shades more " | |
| "aggressively when fruit quality is most at stake.\n\n" | |
| "**Zone weighting:** top canopy 60%, fruiting zone 30%, trunk 10% — " | |
| "penalises shading productive upper leaves, which generate most of the " | |
| "vine's energy.\n\n" | |
| "**Real-time override:** the DP plan is a *schedule*, not a command. Live " | |
| "sensor readings override if the forecast diverges from reality.\n\n" | |
| "**Fallback:** if anything fails — sensor dropout, model error, comms loss — " | |
| "the tracker reverts to full astronomical tracking (zero sacrifice)." | |
| ) | |
| st.markdown("## 8. Key Takeaways") | |
| st.markdown(""" | |
| - **Farquhar model** → reliable 15-min photosynthesis measurements from on-site sensors | |
| - **Four ML layers** → day-ahead prediction with uncertainty (cross-sectional → time-series → Chronos-2 → Gemini routing) | |
| - **Shadow model** → physics engine for evaluating tilt-angle effects on each canopy zone | |
| - **Tracker control** → budget-constrained DP optimization; minimum-dose shading; ≤ 5% energy sacrifice guaranteed | |
| - **LiCor calibration** → manual gas-exchange ground truth triggers automated model retraining and versioned deployment | |
| - **Full story** → measure → predict → simulate → control → calibrate — each module feeds the next | |
| """) | |
| st.markdown("Learn more at [solarwine.ai](https://www.solarwine.ai/)") | |