Download ui/tab_shading.py from SolarWine/api: direct link, hf CLI and curl.
- Browser
- Download file 44.6 kB
-
https://huggingface.co/spaces/SolarWine/api/resolve/main/ui/tab_shading.py
- Command line
-
hf download hf://spaces/SolarWine/api/ui/tab_shading.py
-
curl -L -o tab_shading.py https://huggingface.co/spaces/SolarWine/api/resolve/main/ui/tab_shading.py
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
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)