api / src /shading /tracker_optimizer.py
Eli Safra
Deploy SolarWine API (FastAPI + Docker, port 7860)
938949f
"""
Tracker Optimizer: simulate agrivoltaic shading scenarios.
Uses FarquharModel + ShadowModel to compute A at different tracker tilt
angles, then finds the optimal energy/crop tradeoff.
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from src.farquhar_model import FarquharModel
from src.solar_geometry import ShadowModel
_model = FarquharModel()
_shadow = ShadowModel()
def load_sensor_data() -> pd.DataFrame:
"""Load sensor sample, filter daytime PAR > 50, add helper columns."""
from src.sensor_data_loader import SensorDataLoader
loader = SensorDataLoader()
df = loader.load()
df = loader.filter_daytime(df)
df["time"] = pd.to_datetime(df["time"], utc=True)
df["hour"] = df["time"].dt.hour + df["time"].dt.minute / 60
df["date"] = df["time"].dt.date
df["delta_t"] = df["Air1_leafTemperature_ref"] - df["Air1_airTemperature_ref"]
return df
def compute_stress_heatmap(df: pd.DataFrame) -> pd.DataFrame:
"""Pivot table: hour-of-day (int) vs date, values = mean deltaT.
Restricted to daytime hours (5:00-19:00 UTC) — no stress at night."""
tmp = df.copy()
tmp["hour_int"] = tmp["time"].dt.hour
# Keep only daytime hours (sunrise ~5 UTC, sunset ~19 UTC for Sde Boker)
tmp = tmp[(tmp["hour_int"] >= 5) & (tmp["hour_int"] <= 19)]
pivot = tmp.pivot_table(
values="delta_t", index="hour_int", columns="date", aggfunc="mean",
)
# Ensure all daytime hours are represented even if some have no data
full_hours = list(range(5, 20))
pivot = pivot.reindex(full_hours)
return pivot
def _compute_A_at_par(row: pd.Series, par_factor: float) -> float:
"""Compute A for a single row with PAR scaled by par_factor."""
par = float(row["Air1_PAR_ref"]) * par_factor
if par <= 0:
return 0.0
return _model.calc_photosynthesis(
PAR=par,
Tleaf=float(row["Air1_leafTemperature_ref"]),
CO2=float(row["Air1_CO2_ref"]),
VPD=float(row["Air1_VPD_ref"]),
Tair=float(row["Air1_airTemperature_ref"]),
)
def _compute_A_at_par_value(row: pd.Series, par_value: float) -> float:
"""Compute A for a single row with an absolute PAR value."""
if par_value <= 0:
return 0.0
return _model.calc_photosynthesis(
PAR=par_value,
Tleaf=float(row["Air1_leafTemperature_ref"]),
CO2=float(row["Air1_CO2_ref"]),
VPD=float(row["Air1_VPD_ref"]),
Tair=float(row["Air1_airTemperature_ref"]),
)
def simulate_tilt_angles(
df: pd.DataFrame,
angles: list[int] | None = None,
) -> pd.DataFrame:
"""
For each tilt angle offset from astronomical, compute mean A and energy
fraction across the dataset using the shadow model.
Returns DataFrame with columns: angle, energy_pct, mean_A, A_pct.
"""
if angles is None:
angles = [0, 5, 10, 15, 20, 25, 30, 35, 40, 45]
# Precompute solar positions for all timestamps
times = pd.DatetimeIndex(df["time"])
solar_pos = _shadow.get_solar_position(times)
# Baseline A at astronomical tracking (offset=0)
baseline_A_values = []
for idx, (_, row) in enumerate(df.iterrows()):
elev = solar_pos["solar_elevation"].iloc[idx]
azim = solar_pos["solar_azimuth"].iloc[idx]
if elev <= 2:
baseline_A_values.append(0.0)
continue
tracker = _shadow.compute_tracker_tilt(azim, elev)
theta_astro = tracker["tracker_theta"]
mask = _shadow.project_shadow(elev, azim, theta_astro)
par_dist = _shadow.compute_par_distribution(
float(row["Air1_PAR_ref"]), mask,
solar_elevation=elev, solar_azimuth=azim, tracker_tilt=theta_astro)
# Canopy-average PAR (weighted by LAI)
par_avg = float(np.average(par_dist, weights=_shadow.lai_weights, axis=0).mean())
baseline_A_values.append(_compute_A_at_par_value(row, par_avg))
baseline_A = np.mean(baseline_A_values)
results = []
for angle_offset in angles:
A_values = []
energy_factors = []
for idx, (_, row) in enumerate(df.iterrows()):
elev = solar_pos["solar_elevation"].iloc[idx]
azim = solar_pos["solar_azimuth"].iloc[idx]
if elev <= 2:
A_values.append(0.0)
energy_factors.append(1.0)
continue
tracker = _shadow.compute_tracker_tilt(azim, elev)
theta_astro = tracker["tracker_theta"]
aoi_astro = tracker["aoi"]
# Apply offset
theta_shade = theta_astro + angle_offset
mask = _shadow.project_shadow(elev, azim, theta_shade)
par_dist = _shadow.compute_par_distribution(
float(row["Air1_PAR_ref"]), mask,
solar_elevation=elev, solar_azimuth=azim, tracker_tilt=theta_shade)
par_avg = float(np.average(par_dist, weights=_shadow.lai_weights, axis=0).mean())
A_values.append(_compute_A_at_par_value(row, par_avg))
# Energy: cos(aoi) ratio between offset and astronomical
cos_astro = max(0.0, np.cos(np.radians(aoi_astro)))
cos_offset = max(0.0, np.cos(np.radians(aoi_astro + angle_offset)))
energy_factors.append(
cos_offset / cos_astro if cos_astro > 0.01 else 1.0)
mean_A = np.mean(A_values)
energy_pct = np.mean(energy_factors) * 100
A_pct = (mean_A / baseline_A * 100) if baseline_A > 0 else 0
results.append({
"angle": angle_offset,
"energy_pct": energy_pct,
"mean_A": mean_A,
"A_pct": A_pct,
})
return pd.DataFrame(results)
def compute_daily_schedule(
df: pd.DataFrame,
stress_threshold: float = 2.0,
shade_angle: int = 20,
) -> pd.DataFrame:
"""
For each 15-min slot: compute astronomical tracking angle (pvlib),
and if deltaT > threshold, offset by shade_angle to shade the vine.
Computes A for both strategies using the shadow model.
"""
times = pd.DatetimeIndex(df["time"])
solar_pos = _shadow.get_solar_position(times)
records = []
for idx, (_, row) in enumerate(df.iterrows()):
dt = float(row["delta_t"]) if pd.notna(row["delta_t"]) else 0.0
stressed = dt > stress_threshold
elev = solar_pos["solar_elevation"].iloc[idx]
azim = solar_pos["solar_azimuth"].iloc[idx]
if elev <= 2:
records.append({
"time": row["time"],
"hour": row["hour"],
"delta_t": dt,
"stressed": stressed,
"tracker_angle": 0.0,
"recommended_angle": 0.0,
"A_baseline": 0.0,
"A_smart": 0.0,
"energy_fraction": 1.0,
})
continue
# Astronomical tracking angle (full sun-following)
tracker = _shadow.compute_tracker_tilt(azim, elev)
theta_astro = tracker["tracker_theta"]
aoi_astro = tracker["aoi"]
# Baseline: full astronomical tracking
mask_baseline = _shadow.project_shadow(elev, azim, theta_astro)
par_dist_baseline = _shadow.compute_par_distribution(
float(row["Air1_PAR_ref"]), mask_baseline,
solar_elevation=elev, solar_azimuth=azim, tracker_tilt=theta_astro)
par_avg_baseline = float(
np.average(par_dist_baseline, weights=_shadow.lai_weights, axis=0).mean())
A_baseline = _compute_A_at_par_value(row, par_avg_baseline)
# Smart: offset by shade_angle when stressed
if stressed:
theta_smart = theta_astro + shade_angle
mask_smart = _shadow.project_shadow(elev, azim, theta_smart)
par_dist_smart = _shadow.compute_par_distribution(
float(row["Air1_PAR_ref"]), mask_smart,
solar_elevation=elev, solar_azimuth=azim, tracker_tilt=theta_smart)
par_avg_smart = float(
np.average(par_dist_smart, weights=_shadow.lai_weights, axis=0).mean())
A_smart = _compute_A_at_par_value(row, par_avg_smart)
cos_astro = max(0.0, np.cos(np.radians(aoi_astro)))
cos_offset = max(0.0, np.cos(np.radians(aoi_astro + shade_angle)))
energy_frac = cos_offset / cos_astro if cos_astro > 0.01 else 1.0
else:
theta_smart = theta_astro
A_smart = A_baseline
energy_frac = 1.0
records.append({
"time": row["time"],
"hour": row["hour"],
"delta_t": dt,
"stressed": stressed,
"tracker_angle": theta_astro,
"recommended_angle": theta_smart,
"A_baseline": A_baseline,
"A_smart": A_smart,
"energy_fraction": energy_frac,
})
return pd.DataFrame(records)
def compute_season_summary(schedule: pd.DataFrame) -> dict:
"""Aggregate season totals from the daily schedule."""
total_slots = len(schedule)
stress_slots = schedule["stressed"].sum()
stress_hours = stress_slots * 0.25 # 15-min slots
energy_baseline = total_slots # each slot = 1 unit at full tracking
energy_smart = schedule["energy_fraction"].sum()
energy_pct = (energy_smart / energy_baseline * 100) if energy_baseline > 0 else 100
A_baseline_total = schedule["A_baseline"].sum()
A_smart_total = schedule["A_smart"].sum()
A_change_pct = ((A_smart_total - A_baseline_total) / A_baseline_total * 100) if A_baseline_total > 0 else 0
# Water savings estimate: each stress hour shaded reduces transpiration demand
water_savings_pct = min(30.0, stress_hours * 0.08)
return {
"energy_pct": energy_pct,
"A_baseline_total": A_baseline_total,
"A_smart_total": A_smart_total,
"A_change_pct": A_change_pct,
"stress_hours": stress_hours,
"total_hours": total_slots * 0.25,
"water_savings_pct": water_savings_pct,
}