api / ui /tab_shading.py
safraeli's picture
Deploy: 2026 sensor migration + redesign + bucket B endpoints
13fc29d verified
Raw History Blame Contribute Delete
44.6 kB
""" Shading tab. """ from __future__ import annotations import streamlit as st import pandas as pd import numpy as np from config import settings from ui.bootstrap import _BRAND_GREEN, _HAS_PLOTLY if _HAS_PLOTLY: import plotly.express as px import plotly.graph_objects as go from plotly.subplots import make_subplots def render_tab_shading() -> None: st.header("Panel vs Open Sky — Reference & Test Comparison") st.markdown( "This tab compares vine conditions **under solar panels** (test, in full astronomical " "tracking mode) vs **open sky** (reference, no panels). The panels are always tracking " "the sun \u2014 no smart shading algorithm is active yet. These visualizations show " "the baseline effect of the panels on light, shadow geometry, and modeled photosynthesis." ) st.caption( "\u2139\ufe0f Historical analysis \u2014 the underlying `sensors_wide.csv` archive is frozen at " "Feb 2026 (the 2026 sensor refresh has a different schema). For live photosynthesis " "and tracker state, use the **Vineyard Advisor** and **System Status** tabs." ) try: from src.tracker_optimizer import load_sensor_data @st.cache_data def _opt_load(): return load_sensor_data() df_opt = _opt_load() from src.solar_geometry import ShadowModel from src.canopy_photosynthesis import CanopyPhotosynthesisModel import pvlib shadow_model = ShadowModel() canopy_model = CanopyPhotosynthesisModel(shadow_model=shadow_model) # Inputs: date + hour (local time) import datetime as _dt _LOCAL_TZ = "Asia/Jerusalem" # Default to a growing-season date — sensor data is May-Sep only _today = _dt.date.today() _default_date = _today if 5 <= _today.month <= 9 else _dt.date(_today.year, 7, 15) if "date" in df_opt.columns: _sensor_dates = sorted(df_opt["date"].unique()) if _sensor_dates: _default_date = _sensor_dates[len(_sensor_dates) // 2] viz_date = st.date_input("Date", value=_default_date) # Compute sunrise/sunset for this date to set slider range _noon_local = pd.Timestamp(viz_date, tz=_LOCAL_TZ) + pd.Timedelta(hours=12) _loc = pvlib.location.Location(shadow_model.lat, shadow_model.lon, altitude=shadow_model.altitude, tz=_LOCAL_TZ) _sun_times = _loc.get_sun_rise_set_transit( pd.DatetimeIndex([_noon_local]), ) _sunrise_h = int(_sun_times["sunrise"].iloc[0].hour) _sunset_h = min(20, int(_sun_times["sunset"].iloc[0].hour) + 1) _default_h = (_sunrise_h + _sunset_h) // 2 # midday # --- Section 1: DLI (Daily Light Integral) — date-level, no hour dependency --- st.markdown("---") st.subheader("1. How much total light reaches the vine?") with st.expander("What is DLI and how to read the chart?"): st.markdown( "**Daily Light Integral (DLI)** is the total number of photosynthetically " "active photons received per square meter over a full day, expressed in " "**mol m\u207b\u00b2 d\u207b\u00b9**. It integrates both light intensity and duration.\n\n" "**Formula:** DLI = \u03a3(PAR\u1d62 \u00d7 \u0394t) / 1,000,000\n\n" "**Grapevine thresholds:**\n" "- < 5 mol/m\u00b2/d \u2014 severely limiting\n" "- 5\u201315 mol/m\u00b2/d \u2014 sub-optimal, reduced yield\n" "- 15\u201330 mol/m\u00b2/d \u2014 adequate to optimal\n" "- > 30 mol/m\u00b2/d \u2014 above light saturation, no extra benefit\n\n" "**The 3-panel chart below:**\n" "- **Top:** PAR through the day for reference (orange) and test (green). " "The area under each curve is the DLI.\n" "- **Middle:** Tracker rotation angle from pvlib (positive = tilted east).\n" "- **Bottom:** % of canopy shaded by the panel at each time step.\n\n" "_Reference: Faust & Logan (2018), Purdue Extension HO-238-W_" ) @st.cache_data def _compute_dli(date_str): """Compute DLI for ref and test on a given date, 15-min resolution.""" _tz = "Asia/Jerusalem" _date_local = pd.Timestamp(date_str, tz=_tz) _times = pd.date_range( _date_local + pd.Timedelta(hours=4), _date_local + pd.Timedelta(hours=21), freq="15min", ) _solar = shadow_model.get_solar_position(_times) dt_seconds = 15 * 60 # 900 s per step # Try to get measured PAR for this date _date_only = pd.Timestamp(date_str) _df_day = None if "time" in df_opt.columns: _ts = pd.to_datetime(df_opt["time"], utc=True) _day_mask = _ts.dt.date == _date_only.date() if _day_mask.sum() > 10: _df_day = df_opt.loc[_day_mask].copy() _df_day["_ts"] = _ts[_day_mask] par_ref_sum = 0.0 par_test_sum = 0.0 steps = [] for idx in range(len(_times)): elev = _solar["solar_elevation"].iloc[idx] azim = _solar["solar_azimuth"].iloc[idx] t = _times[idx] # Use local time for display t_local = t.tz_convert(_tz) if t.tzinfo else t h = t_local.hour + t_local.minute / 60 if elev <= 2: steps.append(dict( hour=h, par_ref=0.0, par_test=0.0, tracker_theta=0.0, shaded_pct=0.0, sunlit_pct=100.0, ref_shaded_pct=0.0, )) continue # PAR: use measured if available, else clear-sky estimate if _df_day is not None: _near = (_df_day["_ts"] - t).abs() _closest = _near.idxmin() if _near[_closest] < pd.Timedelta(minutes=20): par_full = max(0.0, float(_df_day.loc[_closest, "Air1_PAR_ref"])) else: par_full = max(0.0, 2000.0 * np.sin(np.radians(elev))) else: par_full = max(0.0, 2000.0 * np.sin(np.radians(elev))) # Tracker angle from pvlib (energy-maximizing mode) _info = shadow_model.compute_tracker_tilt(azim, elev) _theta = _info["tracker_theta"] # Reference: self-shading only (no panels) _fs_ref = shadow_model.compute_face_shading( elev, azim, _theta, include_panels=False, ) _sunlit_ref = _fs_ref["sunlit_fraction"] _shaded_ref = 1.0 - _sunlit_ref # Test: panel + self-shading _fs = shadow_model.compute_face_shading(elev, azim, _theta) _sunlit = _fs["sunlit_fraction"] _shaded = 1.0 - _sunlit # Effective canopy-average PAR (self-shaded faces get diffuse) par_ref = par_full * (_sunlit_ref + _shaded_ref * 0.15) par_test = par_full * (_sunlit + _shaded * 0.15) par_ref_sum += par_ref * dt_seconds par_test_sum += par_test * dt_seconds steps.append(dict( hour=h, par_ref=par_ref, par_test=par_test, tracker_theta=_theta, shaded_pct=_shaded * 100, sunlit_pct=_sunlit * 100, ref_shaded_pct=_shaded_ref * 100, )) dli_ref = par_ref_sum / 1_000_000 dli_test = par_test_sum / 1_000_000 return dli_ref, dli_test, pd.DataFrame(steps) dli_ref, dli_test, dli_df = _compute_dli(str(viz_date)) # DLI metrics side by side col_dli_ref, col_dli_test, col_dli_diff = st.columns(3) col_dli_ref.metric("DLI Reference (open sky)", f"{dli_ref:.1f} mol/m\u00b2/d") col_dli_test.metric("DLI Test (under panel)", f"{dli_test:.1f} mol/m\u00b2/d") if dli_ref > 0: _dli_pct = dli_test / dli_ref * 100 col_dli_diff.metric("Light retained", f"{_dli_pct:.0f}%", delta=f"{dli_test - dli_ref:.1f} mol/m\u00b2/d") st.markdown( f"Grapevine range: Ref = **{dli_ref:.1f}** | " f"Test = **{dli_test:.1f}** mol/m\u00b2/d " f"({'adequate' if dli_test >= 15 else 'sub-optimal' if dli_test >= 5 else 'severely limiting'} " f"for grapevine)" ) # 3-panel chart: PAR, tracker angle, shading % if _HAS_PLOTLY: fig_dli = make_subplots( rows=3, cols=1, shared_xaxes=True, row_heights=[0.5, 0.25, 0.25], subplot_titles=[ f"PAR through the day \u2014 {viz_date} (area = DLI)", "Tracker rotation angle (pvlib)", "Canopy shading", ], vertical_spacing=0.08, ) # Row 1: PAR ref vs test fig_dli.add_trace(go.Scatter( x=dli_df["hour"], y=dli_df["par_ref"], mode="lines", name="Reference (open sky)", line=dict(color="#FF8C00", width=2), fill="tozeroy", fillcolor="rgba(255,140,0,0.15)", ), row=1, col=1) fig_dli.add_trace(go.Scatter( x=dli_df["hour"], y=dli_df["par_test"], mode="lines", name="Test (under panel)", line=dict(color=_BRAND_GREEN, width=2), fill="tozeroy", fillcolor="rgba(0,189,62,0.15)", ), row=1, col=1) # DLI annotations on PAR chart _par_max = dli_df["par_ref"].max() if _par_max > 0: fig_dli.add_annotation( x=12, y=_par_max * 0.95, text=f"DLI ref = {dli_ref:.1f} mol/m\u00b2/d", showarrow=False, font=dict(color="#FF8C00", size=12), row=1, col=1, ) fig_dli.add_annotation( x=12, y=_par_max * 0.80, text=f"DLI test = {dli_test:.1f} mol/m\u00b2/d", showarrow=False, font=dict(color=_BRAND_GREEN, size=12), row=1, col=1, ) # Row 2: Tracker angle fig_dli.add_trace(go.Scatter( x=dli_df["hour"], y=dli_df["tracker_theta"], mode="lines", name="Tracker \u03b8", line=dict(color="#6A5ACD", width=2), ), row=2, col=1) fig_dli.add_hline(y=0, line_dash="dot", line_color="gray", row=2, col=1) # Row 3: Shading % fig_dli.add_trace(go.Scatter( x=dli_df["hour"], y=dli_df["shaded_pct"], mode="lines", name="Shaded %", line=dict(color="#b2182b", width=2), fill="tozeroy", fillcolor="rgba(178,24,43,0.15)", ), row=3, col=1) fig_dli.update_yaxes(title_text="PAR (\u00b5mol m\u207b\u00b2 s\u207b\u00b9)", row=1, col=1) fig_dli.update_yaxes(title_text="Angle (\u00b0)", row=2, col=1) fig_dli.update_yaxes(title_text="Shaded (%)", range=[0, 100], row=3, col=1) fig_dli.update_xaxes(title_text="Hour (local time)", row=3, col=1) fig_dli.update_layout(height=650, showlegend=True) st.plotly_chart(fig_dli, key="dli_chart") # 15-min detail table with st.expander("15-min resolution data"): _show_df = dli_df[dli_df["par_ref"] > 0][ ["hour", "par_ref", "par_test", "tracker_theta", "shaded_pct", "sunlit_pct", "ref_shaded_pct"] ].copy() _show_df.columns = ["Hour (local)", "PAR ref", "PAR test", "Tracker \u03b8 (\u00b0)", "Test shaded %", "Test sunlit %", "Ref shaded %"] for c in _show_df.columns[1:]: _show_df[c] = _show_df[c].round(1) st.dataframe(_show_df, hide_index=True) # ---- Section 2: 3D Shadow Model ---- st.markdown("---") st.subheader("2. 3D view: where shadows fall") with st.expander("How to read this visualization"): st.markdown( "Two interactive 3D scenes show the vine canopy at the selected date and hour.\n\n" "- **Left (Test):** canopy under solar panels. Dark red patches are shaded " "by the panel; green patches receive direct sun.\n" "- **Right (Reference):** same canopy with no panels. Only self-shading " "(the far side of the canopy) appears.\n\n" "The yellow arrow shows the sun direction. Metrics above the scenes report " "the % of each canopy face (top, east, west) that is sunlit. " "Rotate the 3D view by dragging to see shading from different angles.\n\n" "**Key insight:** even under panels, a large fraction of the canopy " "stays sunlit because the shadow is narrow and moves with the tracker." ) viz_hour_local = st.slider( "Hour (local time)", _sunrise_h, _sunset_h, _default_h, ) # Convert local time to UTC for solar position viz_time = pd.DatetimeIndex([ pd.Timestamp(viz_date, tz=_LOCAL_TZ) + pd.Timedelta(hours=viz_hour_local) ]) solar_pos = shadow_model.get_solar_position(viz_time) sol_elev = solar_pos["solar_elevation"].iloc[0] sol_az = solar_pos["solar_azimuth"].iloc[0] if sol_elev > 0: tracker_info = shadow_model.compute_tracker_tilt(sol_az, sol_elev) tracker_tilt = tracker_info["tracker_theta"] tracker_aoi = tracker_info["aoi"] tracker_surface_tilt = tracker_info["surface_tilt"] tracker_surface_az = tracker_info["surface_azimuth"] shadow_mask = shadow_model.project_shadow(sol_elev, sol_az, tracker_tilt) face_shading = shadow_model.compute_face_shading(sol_elev, sol_az, tracker_tilt) face_bounds = shadow_model.compute_face_shadow_bounds(sol_elev, sol_az, tracker_tilt) # Reference: self-shading only (no panels) face_shading_ref = shadow_model.compute_face_shading( sol_elev, sol_az, tracker_tilt, include_panels=False, ) face_bounds_ref = shadow_model.compute_face_shadow_bounds( sol_elev, sol_az, tracker_tilt, include_panels=False, ) # Face-aware sunlit area (top + east + west faces) _total_area = face_shading["total_area"] # m²/m of row _sunlit_area_ref = face_shading_ref["total_sunlit_area"] sunlit_frac_ref = face_shading_ref["sunlit_fraction"] _sunlit_area_test = face_shading["total_sunlit_area"] sunlit_frac_test = face_shading["sunlit_fraction"] # Solar position & tracker metrics c1, c2, c3, c4, c5 = st.columns(5) c1.metric("Sun elevation", f"{sol_elev:.1f}\u00b0") c2.metric("Sun azimuth", f"{sol_az:.1f}\u00b0") c3.metric("Tracker rotation", f"{tracker_tilt:.1f}\u00b0") c4.metric("Surface tilt", f"{tracker_surface_tilt:.1f}\u00b0") c5.metric("Angle of incidence", f"{tracker_aoi:.1f}\u00b0") st.caption( f"Tracker axis: {shadow_model.row_azimuth:.0f}\u00b0 (NW\u2013SE) | " f"Panel faces {tracker_surface_az:.0f}\u00b0 | " f"Rotation {tracker_tilt:+.1f}\u00b0 (pvlib.tracking.singleaxis, " f"backtrack=True, GCR={shadow_model.panel_width/shadow_model.row_spacing:.2f})" ) # Per-face shading breakdown — Test (with panels) vs Reference (no panels) st.markdown("**With panels** (test)") c1, c2, c3, c4 = st.columns(4) c1.metric("Top face sunlit", f"{face_shading['top_sunlit']*100:.0f}%", help=f"Area: {face_shading['top_area']:.1f} m\u00b2/m") c2.metric("East face sunlit", f"{face_shading['east_sunlit']*100:.0f}%", help=f"Area: {face_shading['east_area']:.1f} m\u00b2/m") c3.metric("West face sunlit", f"{face_shading['west_sunlit']*100:.0f}%", help=f"Area: {face_shading['west_area']:.1f} m\u00b2/m") c4.metric("Total sunlit", f"{sunlit_frac_test*100:.0f}%", help=f"{_sunlit_area_test:.2f} / {_total_area:.1f} m\u00b2/m") st.markdown("**Without panels** (reference)") r1, r2, r3, r4 = st.columns(4) r1.metric("Top face sunlit", f"{face_shading_ref['top_sunlit']*100:.0f}%", help=f"Area: {face_shading_ref['top_area']:.1f} m\u00b2/m") r2.metric("East face sunlit", f"{face_shading_ref['east_sunlit']*100:.0f}%", help=f"Area: {face_shading_ref['east_area']:.1f} m\u00b2/m") r3.metric("West face sunlit", f"{face_shading_ref['west_sunlit']*100:.0f}%", help=f"Area: {face_shading_ref['west_area']:.1f} m\u00b2/m") r4.metric("Total sunlit", f"{sunlit_frac_ref*100:.0f}%", help=f"{_sunlit_area_ref:.2f} / {_total_area:.1f} m\u00b2/m") if _HAS_PLOTLY: # --- Helper: build a quad (2 triangles) from 4 world-coord corners --- def _add_quad(fig, pts, color, opacity, name=None, showlegend=False): """Add a quadrilateral (4 corners) as 2 triangles.""" fig.add_trace(go.Mesh3d( x=[p[0] for p in pts], y=[p[1] for p in pts], z=[p[2] for p in pts], i=[0, 0], j=[1, 2], k=[2, 3], color=color, opacity=opacity, name=name, showlegend=showlegend, flatshading=True, )) # --- Helper to build 3D scene --- def _build_3d_scene(show_panels: bool, title: str): fig = go.Figure() n_rows = shadow_model.n_rows spacing = shadow_model.row_spacing cw2 = shadow_model.canopy_width / 2 ch = shadow_model.canopy_height half_len = 1.5 # along-row length for visualization _row_u = shadow_model._row_u _row_v = shadow_model._row_v _green = _BRAND_GREEN _shade_color = "#8B0000" for row_idx in range(n_rows): offset = (row_idx - n_rows // 2) * spacing show_legend = (row_idx == 0) ox = offset * _row_v[0] oy = offset * _row_v[1] if show_panels: fb = face_bounds # panel + self-shading else: fb = face_bounds_ref # self-shading only # Helper: world coord from local (v, u, z) def _w(v, u, z): return [v*_row_v[0] + u*_row_u[0] + ox, v*_row_v[1] + u*_row_u[1] + oy, z] u0, u1 = -half_len, half_len # --- Top face --- top_sh = fb["top_shaded"] if top_sh is not None: sv_lo, sv_hi = max(top_sh[0], -cw2), min(top_sh[1], cw2) if sv_lo < sv_hi: # Shaded portion _add_quad(fig, [_w(sv_lo,u0,ch), _w(sv_hi,u0,ch), _w(sv_hi,u1,ch), _w(sv_lo,u1,ch)], _shade_color, 0.5, "Shaded" if show_legend else None, show_legend) # Sunlit portions (left and right of shade) if sv_lo > -cw2: _add_quad(fig, [_w(-cw2,u0,ch), _w(sv_lo,u0,ch), _w(sv_lo,u1,ch), _w(-cw2,u1,ch)], _green, 0.4) if sv_hi < cw2: _add_quad(fig, [_w(sv_hi,u0,ch), _w(cw2,u0,ch), _w(cw2,u1,ch), _w(sv_hi,u1,ch)], _green, 0.4) else: _add_quad(fig, [_w(-cw2,u0,ch), _w(cw2,u0,ch), _w(cw2,u1,ch), _w(-cw2,u1,ch)], _green, 0.4) else: _add_quad(fig, [_w(-cw2,u0,ch), _w(cw2,u0,ch), _w(cw2,u1,ch), _w(-cw2,u1,ch)], _green, 0.4, "Sunlit" if show_legend else None, show_legend) # --- East face (positive v = +cw2) --- east_sh = fb["east_shaded"] if east_sh is not None: sz_lo, sz_hi = max(east_sh[0], 0), min(east_sh[1], ch) if sz_lo < sz_hi: _add_quad(fig, [_w(cw2,u0,sz_lo), _w(cw2,u0,sz_hi), _w(cw2,u1,sz_hi), _w(cw2,u1,sz_lo)], _shade_color, 0.5) if sz_lo > 0: _add_quad(fig, [_w(cw2,u0,0), _w(cw2,u0,sz_lo), _w(cw2,u1,sz_lo), _w(cw2,u1,0)], _green, 0.4) if sz_hi < ch: _add_quad(fig, [_w(cw2,u0,sz_hi), _w(cw2,u0,ch), _w(cw2,u1,ch), _w(cw2,u1,sz_hi)], _green, 0.4) else: _add_quad(fig, [_w(cw2,u0,0), _w(cw2,u0,ch), _w(cw2,u1,ch), _w(cw2,u1,0)], _green, 0.4) else: _add_quad(fig, [_w(cw2,u0,0), _w(cw2,u0,ch), _w(cw2,u1,ch), _w(cw2,u1,0)], _green, 0.4) # --- West face (negative v = -cw2) --- west_sh = fb["west_shaded"] if west_sh is not None: sz_lo, sz_hi = max(west_sh[0], 0), min(west_sh[1], ch) if sz_lo < sz_hi: _add_quad(fig, [_w(-cw2,u0,sz_lo), _w(-cw2,u0,sz_hi), _w(-cw2,u1,sz_hi), _w(-cw2,u1,sz_lo)], _shade_color, 0.5) if sz_lo > 0: _add_quad(fig, [_w(-cw2,u0,0), _w(-cw2,u0,sz_lo), _w(-cw2,u1,sz_lo), _w(-cw2,u1,0)], _green, 0.4) if sz_hi < ch: _add_quad(fig, [_w(-cw2,u0,sz_hi), _w(-cw2,u0,ch), _w(-cw2,u1,ch), _w(-cw2,u1,sz_hi)], _green, 0.4) else: _add_quad(fig, [_w(-cw2,u0,0), _w(-cw2,u0,ch), _w(-cw2,u1,ch), _w(-cw2,u1,0)], _green, 0.4) else: _add_quad(fig, [_w(-cw2,u0,0), _w(-cw2,u0,ch), _w(-cw2,u1,ch), _w(-cw2,u1,0)], _green, 0.4) if show_panels: # Solar panel corners = shadow_model.panel_corners_world(tracker_tilt, offset) fig.add_trace(go.Mesh3d( x=corners[:, 0].tolist(), y=corners[:, 1].tolist(), z=corners[:, 2].tolist(), i=[0, 0], j=[1, 2], k=[2, 3], color="#333333", opacity=0.8, name="Panel" if show_legend else None, showlegend=show_legend, flatshading=True, )) # Sun direction arrow elev_rad = np.radians(sol_elev) az_rad = np.radians(sol_az) arrow_len = 3.0 sun_dx = np.cos(elev_rad) * np.sin(az_rad) * arrow_len sun_dy = np.cos(elev_rad) * np.cos(az_rad) * arrow_len sun_dz = np.sin(elev_rad) * arrow_len fig.add_trace(go.Scatter3d( x=[sun_dx, 0], y=[sun_dy, 0], z=[shadow_model.panel_height + sun_dz, shadow_model.panel_height], mode="lines", line=dict(color="#FFD700", width=6), name="Sun ray", )) # Compass labels _range = n_rows * spacing / 2 + 2 for label, dx, dy in [("N", 0, _range), ("S", 0, -_range), ("E", _range, 0), ("W", -_range, 0)]: fig.add_trace(go.Scatter3d( x=[dx], y=[dy], z=[0], mode="text", text=[label], textfont=dict(size=14, color="gray"), showlegend=False, )) fig.update_layout( scene=dict( xaxis_title="East (m)", yaxis_title="North (m)", zaxis_title="Height (m)", aspectmode="data", camera=dict(eye=dict(x=1.5, y=-2, z=1.2)), ), height=500, showlegend=True, title=title, margin=dict(l=0, r=0, t=40, b=0), ) return fig # --- Side-by-side: Test (panels) vs Reference (no panels) --- col_test, col_ref = st.columns(2) with col_test: st.markdown( f"### Test (under panel)\n" f"**Direct sun: {_sunlit_area_test:.2f} / {_total_area:.1f} m\u00b2/m** " f"({sunlit_frac_test*100:.0f}%)\n\n" f"Top: {face_shading['top_sunlit']*100:.0f}% | " f"East: {face_shading['east_sunlit']*100:.0f}% | " f"West: {face_shading['west_sunlit']*100:.0f}%" ) fig_test = _build_3d_scene( show_panels=True, title=f"Test \u2014 {viz_hour_local}:00, {viz_date}", ) st.plotly_chart(fig_test, key="3d_test") with col_ref: st.markdown( f"### Reference (open sky)\n" f"**Direct sun: {_sunlit_area_ref:.2f} / {_total_area:.1f} m\u00b2/m** " f"({sunlit_frac_ref*100:.0f}%)\n\n" f"Top: {face_shading_ref['top_sunlit']*100:.0f}% | " f"East: {face_shading_ref['east_sunlit']*100:.0f}% | " f"West: {face_shading_ref['west_sunlit']*100:.0f}%" ) fig_ref = _build_3d_scene( show_panels=False, title=f"Reference \u2014 {viz_hour_local}:00, {viz_date}", ) st.plotly_chart(fig_ref, key="3d_ref") # Shadow percentage summary st.markdown( f"At **{viz_hour_local}:00** on **{viz_date}**, " f"reference canopy receives direct sun on **{sunlit_frac_ref*100:.0f}%** " f"of its surface (self-shading on the far side). " f"Under the panel, direct sun drops to **{sunlit_frac_test*100:.0f}%** " f"({_sunlit_area_ref:.2f} \u2192 {_sunlit_area_test:.2f} m\u00b2/m)." ) # --- PAR heatmap --- st.subheader("3. Light map across the canopy (per face)") with st.expander("How to read these heatmaps"): st.markdown( "The canopy box has three light-receiving faces: **West**, " "**Top**, and **East**. Each cell shows the **PAR** " "(\u00b5mol m\u207b\u00b2 s\u207b\u00b9) reaching that face and height zone.\n\n" "- **Bright yellow:** full direct sunlight.\n" "- **Dark blue:** heavily shaded (receiving only diffuse PAR).\n\n" "Side faces (East/West) receive direct beam through row gaps " "when the sun is on their side; the opposite face is self-shaded. " "The Top face value is uniform across all rows." ) # Convert local hour to UTC for sensor data matching _viz_utc_hour = viz_time[0].tz_convert("UTC").hour + viz_time[0].tz_convert("UTC").minute / 60 hour_mask = df_opt["hour"].between(_viz_utc_hour - 0.25, _viz_utc_hour + 0.25) if hour_mask.any(): sample_par = float(df_opt.loc[hour_mask, "Air1_PAR_ref"].median()) else: sample_par = 1000.0 _face_par_test = shadow_model.compute_face_par_zones( sample_par, sol_elev, sol_az, tracker_tilt, include_panels=True, ) _face_par_ref = shadow_model.compute_face_par_zones( sample_par, sol_elev, sol_az, tracker_tilt, include_panels=False, ) _par_cs = [[0, "#1a1a2e"], [0.5, "#e6a817"], [1, "#ffff00"]] _par_max = max( float(max(_face_par_ref["west"].max(), _face_par_ref["top"].max(), _face_par_ref["east"].max())), sample_par, ) _par_yz = ["Bottom", "Middle", "Top"] _par_xt = ["W-edge", "Centre", "E-edge"] def _render_face_par(face_data, scenario_label, key_prefix): st.markdown(f"**{scenario_label}**") cW, cT, cE = st.columns(3) with cW: st.caption("West face") _fig = px.imshow( face_data["west"].reshape(3, 1), x=["West"], y=_par_yz, color_continuous_scale=_par_cs, range_color=[0, _par_max], text_auto=".0f", aspect="auto", ) _fig.update_coloraxes(showscale=False) _fig.update_layout(height=200, margin=dict(l=0, r=0, t=0, b=0)) _fig.update_yaxes(autorange="reversed") st.plotly_chart(_fig, key=f"{key_prefix}_w") with cT: st.caption("Top face") _fig = px.imshow( face_data["top"].reshape(1, 3), x=_par_xt, y=["Top"], color_continuous_scale=_par_cs, range_color=[0, _par_max], text_auto=".0f", aspect="auto", ) _fig.update_coloraxes(showscale=False) _fig.update_layout(height=200, margin=dict(l=0, r=0, t=0, b=0)) st.plotly_chart(_fig, key=f"{key_prefix}_t") with cE: st.caption("East face") _fig = px.imshow( face_data["east"].reshape(3, 1), x=["East"], y=_par_yz, color_continuous_scale=_par_cs, range_color=[0, _par_max], text_auto=".0f", aspect="auto", ) _fig.update_coloraxes(colorbar_title="PAR") _fig.update_layout(height=200, margin=dict(l=0, r=0, t=0, b=0)) _fig.update_yaxes(autorange="reversed") st.plotly_chart(_fig, key=f"{key_prefix}_e") _render_face_par(_face_par_test, "Test (under panel)", "par_test") _render_face_par(_face_par_ref, "Reference (open sky)", "par_ref") # --- Photosynthesis map (3-face view) --- st.subheader("4. Growth activity across the canopy") with st.expander("How to read these heatmaps"): st.markdown( "Same 3-face layout as the PAR map, but now each cell shows the " "**photosynthesis rate A** (\u00b5mol CO\u2082 m\u207b\u00b2 s\u207b\u00b9) computed by the " "Farquhar model for that face\u2019s PAR and the current temperature/VPD/CO\u2082.\n\n" "- **Bright green:** high A \u2014 the zone is photosynthesizing actively.\n" "- **Dark:** low A \u2014 the zone is shaded and limited by light.\n\n" "Notice that A drops less steeply than PAR because photosynthesis " "saturates at high light \u2014 halving PAR from 2000 to 1000 may only " "reduce A by 20\u201330%. The **A_vine** value integrates across all zones." ) if hour_mask.any(): sample_row = df_opt.loc[hour_mask].iloc[len(df_opt.loc[hour_mask])//2] s_tleaf = float(sample_row["Air1_leafTemperature_ref"]) s_co2 = float(sample_row["Air1_CO2_ref"]) s_vpd = float(sample_row["Air1_VPD_ref"]) s_tair = float(sample_row["Air1_airTemperature_ref"]) else: s_tleaf, s_co2, s_vpd, s_tair = 28.0, 400.0, 1.5, 26.0 # A_vine from the existing grid model (for the summary metric) result_panel = canopy_model.compute_vine_A( sample_par, s_tleaf, s_co2, s_vpd, s_tair, shadow_mask, solar_elevation=sol_elev, solar_azimuth=sol_az, tracker_tilt=tracker_tilt, ) result_ref = canopy_model.compute_vine_A( sample_par, s_tleaf, s_co2, s_vpd, s_tair, np.zeros_like(shadow_mask, dtype=bool), solar_elevation=sol_elev, solar_azimuth=sol_az, ) # Compute face-level A from face PAR zones _farq = canopy_model.farquhar def _par_arr_to_A(par_arr): A = np.zeros_like(par_arr) for i in range(len(par_arr)): if par_arr[i] > 0: A[i] = _farq.calc_photosynthesis( PAR=par_arr[i], Tleaf=s_tleaf, CO2=s_co2, VPD=s_vpd, Tair=s_tair, ) return A def _face_to_A(face_par_dict): return { "west": _par_arr_to_A(face_par_dict["west"]), "top": _par_arr_to_A(face_par_dict["top"]), "east": _par_arr_to_A(face_par_dict["east"]), } _A_face_test = _face_to_A(_face_par_test) _A_face_ref = _face_to_A(_face_par_ref) _a_cs = [[0, "#1a1a2e"], [0.5, "#2d6a4f"], [1, _BRAND_GREEN]] _a_max = float(max( _A_face_ref["west"].max(), _A_face_ref["top"].max(), _A_face_ref["east"].max(), )) + 1 _a_yz = ["Bottom", "Middle", "Top"] _a_xt = ["W-edge", "Centre", "E-edge"] def _render_face_A(face_A, scenario_label, a_vine, key_prefix): st.markdown(f"**{scenario_label}** (A_vine = {a_vine:.1f})") cW, cT, cE = st.columns(3) with cW: st.caption("West face") _fig = px.imshow( face_A["west"].reshape(3, 1), x=["West"], y=_a_yz, color_continuous_scale=_a_cs, range_color=[0, _a_max], text_auto=".1f", aspect="auto", ) _fig.update_coloraxes(showscale=False) _fig.update_layout(height=200, margin=dict(l=0, r=0, t=0, b=0)) _fig.update_yaxes(autorange="reversed") st.plotly_chart(_fig, key=f"{key_prefix}_w") with cT: st.caption("Top face") _fig = px.imshow( face_A["top"].reshape(1, 3), x=_a_xt, y=["Top"], color_continuous_scale=_a_cs, range_color=[0, _a_max], text_auto=".1f", aspect="auto", ) _fig.update_coloraxes(showscale=False) _fig.update_layout(height=200, margin=dict(l=0, r=0, t=0, b=0)) st.plotly_chart(_fig, key=f"{key_prefix}_t") with cE: st.caption("East face") _fig = px.imshow( face_A["east"].reshape(3, 1), x=["East"], y=_a_yz, color_continuous_scale=_a_cs, range_color=[0, _a_max], text_auto=".1f", aspect="auto", ) _fig.update_coloraxes(colorbar_title="A") _fig.update_layout(height=200, margin=dict(l=0, r=0, t=0, b=0)) _fig.update_yaxes(autorange="reversed") st.plotly_chart(_fig, key=f"{key_prefix}_e") _render_face_A(_A_face_test, "Under panel", result_panel['A_vine'], "a_panel") _render_face_A(_A_face_ref, "No panel (reference)", result_ref['A_vine'], "a_ref") if result_ref["A_vine"] > 0: st.caption( f"Panel retains {result_panel['A_vine']/result_ref['A_vine']*100:.0f}% " f"of vine-level photosynthesis despite {(1-sunlit_frac_test)*100:.0f}% shadow coverage. " "Shaded zones still photosynthesize using diffuse light." ) # --- Validation: modeled vs measured PAR ratio --- if "Air2_PAR_test" in df_opt.columns: st.subheader("5. Does the model match reality?") with st.expander("How to read this chart"): st.markdown( "Each dot is one 15-min observation. **X-axis:** the 3D model's " "predicted PAR ratio (test/reference). **Y-axis:** the actually " "measured ratio from sensors (Air2\\_PAR\\_test / Air1\\_PAR\\_ref).\n\n" "Points on the **dashed 1:1 line** mean the model perfectly matches " "reality. Systematic offset (dots above/below the line) reveals model " "bias. Scatter indicates noise. A tight cluster around the 1:1 line " "validates the 3D shadow geometry." ) @st.cache_data def _compute_validation(): _df = df_opt.dropna(subset=["Air1_PAR_ref", "Air2_PAR_test"]).copy() _df = _df[_df["Air1_PAR_ref"] > 50] _times = pd.DatetimeIndex(_df["time"]) _solar = shadow_model.get_solar_position(_times) measured_ratio = _df["Air2_PAR_test"].values / _df["Air1_PAR_ref"].values modeled_sunlit = [] for _, srow in _solar.iterrows(): e, a = srow["solar_elevation"], srow["solar_azimuth"] if e > 2: m = shadow_model.project_shadow(e, a) modeled_sunlit.append(shadow_model.sunlit_fraction(m)) else: modeled_sunlit.append(1.0) diffuse = 0.15 modeled_par_ratio = np.array(modeled_sunlit) + (1 - np.array(modeled_sunlit)) * diffuse return measured_ratio, modeled_par_ratio, _times meas_r, mod_r, val_times = _compute_validation() fig_val = go.Figure() fig_val.add_trace(go.Scatter( x=mod_r, y=meas_r, mode="markers", marker=dict(size=3, color=_BRAND_GREEN, opacity=0.3), name="Data", )) fig_val.add_trace(go.Scatter( x=[0, 1.2], y=[0, 1.2], mode="lines", line=dict(dash="dash", color="gray"), name="1:1", )) fig_val.update_layout( title="Modeled vs measured PAR ratio (test/ref)", xaxis_title="Modeled PAR ratio", yaxis_title="Measured PAR ratio (Air2/Air1)", height=400, ) st.plotly_chart(fig_val, key="validation") else: st.warning(f"Sun is below horizon at {viz_hour_local}:00 on {viz_date}.") except Exception as e: st.error(f"Tracker Optimizer error: {e}") import traceback st.code(traceback.format_exc()) # --------------------------------------------------------------------------- # Tab — Vineyard Advisor (Gemini chatbot) # ---------------------------------------------------------------------------
"""
Shading tab.
"""
from __future__ import annotations
import streamlit as st
import pandas as pd
import numpy as np
from config import settings
from ui.bootstrap import _BRAND_GREEN, _HAS_PLOTLY
if _HAS_PLOTLY:
import plotly.express as px
import plotly.graph_objects as go
from plotly.subplots import make_subplots
def render_tab_shading() -> None:
st.header("Panel vs Open Sky — Reference & Test Comparison")
st.markdown(
"This tab compares vine conditions **under solar panels** (test, in full astronomical "
"tracking mode) vs **open sky** (reference, no panels). The panels are always tracking "
"the sun \u2014 no smart shading algorithm is active yet. These visualizations show "
"the baseline effect of the panels on light, shadow geometry, and modeled photosynthesis."
)
st.caption(
"\u2139\ufe0f Historical analysis \u2014 the underlying `sensors_wide.csv` archive is frozen at "
"Feb 2026 (the 2026 sensor refresh has a different schema). For live photosynthesis "
"and tracker state, use the **Vineyard Advisor** and **System Status** tabs."
)
try:
from src.tracker_optimizer import load_sensor_data
@st.cache_data
def _opt_load():
return load_sensor_data()
df_opt = _opt_load()
from src.solar_geometry import ShadowModel
from src.canopy_photosynthesis import CanopyPhotosynthesisModel
import pvlib
shadow_model = ShadowModel()
canopy_model = CanopyPhotosynthesisModel(shadow_model=shadow_model)
# Inputs: date + hour (local time)
import datetime as _dt
_LOCAL_TZ = "Asia/Jerusalem"
# Default to a growing-season date — sensor data is May-Sep only
_today = _dt.date.today()
_default_date = _today if 5 <= _today.month <= 9 else _dt.date(_today.year, 7, 15)
if "date" in df_opt.columns:
_sensor_dates = sorted(df_opt["date"].unique())
if _sensor_dates:
_default_date = _sensor_dates[len(_sensor_dates) // 2]
viz_date = st.date_input("Date", value=_default_date)
# Compute sunrise/sunset for this date to set slider range
_noon_local = pd.Timestamp(viz_date, tz=_LOCAL_TZ) + pd.Timedelta(hours=12)
_loc = pvlib.location.Location(shadow_model.lat, shadow_model.lon,
altitude=shadow_model.altitude, tz=_LOCAL_TZ)