""" PvlibTracker: lightweight single-axis tracker angle calculator using pvlib. Provides GPS-based axis azimuth computation and pvlib single-axis tracking as a complement / validation layer for the main ShadowModel. Adapted from the tracker repo's AsyncSolarTrackerSystem — made synchronous and simplified for Baseline integration. """ from __future__ import annotations import logging from datetime import datetime from typing import Optional import numpy as np import pandas as pd from pvlib import location, tracking from config.settings import ( ROW_AZIMUTH, SITE_ALTITUDE, SITE_LATITUDE, SITE_LONGITUDE, TRACKER_GCR, TRACKER_MAX_ANGLE, ) logger = logging.getLogger(__name__) def axis_azimuth_from_gps( head: tuple[float, float], tail: tuple[float, float], ) -> float: """Compute tracker axis azimuth from head/tail GPS coordinates. Uses the initial bearing (Haversine formula) between two points along the tracker rail to determine the compass direction. Parameters ---------- head : (lat, lon) GPS coordinates of the tracker head (north end). tail : (lat, lon) GPS coordinates of the tracker tail (south end). Returns ------- float Axis azimuth in degrees (0–360, clockwise from north). """ lat1, lon1 = np.radians(head) lat2, lon2 = np.radians(tail) dlon = lon2 - lon1 y = np.sin(dlon) * np.cos(lat2) x = np.cos(lat1) * np.sin(lat2) - np.sin(lat1) * np.cos(lat2) * np.cos(dlon) bearing = np.degrees(np.arctan2(y, x)) return (bearing + 360) % 360 class PvlibTracker: """Single-axis tracker using pvlib for solar position and orientation. Parameters ---------- latitude, longitude, altitude : float Site coordinates. Defaults to Yeruham vineyard. timezone : str IANA timezone. axis_azimuth : float, optional Tracker axis direction (degrees CW from north). If None, computed from ``head_gps`` / ``tail_gps``. head_gps, tail_gps : tuple[float, float], optional GPS coordinates (lat, lon) of tracker endpoints. Used to compute axis_azimuth if not given explicitly. max_angle : float Maximum tracker tilt angle (degrees). gcr : float Ground coverage ratio (panel width / row spacing). backtrack : bool Enable backtracking to avoid inter-row shading. """ # Yeruham vineyard reference GPS coordinates for row axis direction. # All rows share the same NW-SE orientation; individual row offsets # are cross-row only and don't affect the axis azimuth. REFERENCE_GPS = { "head": (30.980222, 34.908192), "tail": (30.979471, 34.909118), } def __init__( self, latitude: float = SITE_LATITUDE, longitude: float = SITE_LONGITUDE, altitude: float = SITE_ALTITUDE, timezone: str = "Asia/Jerusalem", axis_azimuth: Optional[float] = None, head_gps: Optional[tuple[float, float]] = None, tail_gps: Optional[tuple[float, float]] = None, max_angle: float = TRACKER_MAX_ANGLE, gcr: float = TRACKER_GCR, backtrack: bool = True, ): self.site = location.Location( latitude, longitude, timezone, altitude, "Yeruham Vineyard", ) self.timezone = timezone self.max_angle = max_angle self.gcr = gcr self.backtrack = backtrack if axis_azimuth is not None: self.axis_azimuth = axis_azimuth elif head_gps and tail_gps: self.axis_azimuth = axis_azimuth_from_gps(head_gps, tail_gps) else: # Default: use the configured row azimuth (consistent with ShadowModel) self.axis_azimuth = ROW_AZIMUTH def get_solar_position(self, timestamp: datetime) -> dict: """Get solar position for a single timestamp. Returns dict with ``solar_elevation``, ``solar_azimuth``, ``apparent_zenith``. """ ts = pd.Timestamp(timestamp) if ts.tzinfo is None: ts = ts.tz_localize(self.timezone) times = pd.DatetimeIndex([ts]) sp = self.site.get_solarposition(times) return { "solar_elevation": float(sp["apparent_elevation"].iloc[0]), "solar_azimuth": float(sp["azimuth"].iloc[0]), "apparent_zenith": float(sp["apparent_zenith"].iloc[0]), } def get_tracking_angle(self, timestamp: datetime) -> float: """Compute the optimal single-axis tracker tilt for a timestamp. Returns the tracker theta in degrees. Returns 0.0 when sun is below the horizon. """ sp = self.get_solar_position(timestamp) if sp["solar_elevation"] <= 0: return 0.0 result = tracking.singleaxis( sp["apparent_zenith"], sp["solar_azimuth"], axis_tilt=0, axis_azimuth=self.axis_azimuth, max_angle=self.max_angle, backtrack=self.backtrack, gcr=self.gcr, ) theta = result["tracker_theta"] if pd.isna(theta): return 0.0 return float(theta) def get_day_profile( self, target_date: datetime | None = None, freq: str = "15min", ) -> pd.DataFrame: """Compute tracker angles for an entire day. Returns DataFrame with columns: ``tracker_theta``, ``solar_elevation``, ``solar_azimuth``, indexed by timestamp. """ if target_date is None: target_date = pd.Timestamp.now(tz=self.timezone).normalize() else: target_date = pd.Timestamp(target_date) if target_date.tzinfo is None: target_date = target_date.tz_localize(self.timezone) target_date = target_date.normalize() end = target_date + pd.Timedelta(days=1) times = pd.date_range(target_date, end, freq=freq, tz=self.timezone) sp = self.site.get_solarposition(times) tr = tracking.singleaxis( sp["apparent_zenith"], sp["azimuth"], axis_tilt=0, axis_azimuth=self.axis_azimuth, max_angle=self.max_angle, backtrack=self.backtrack, gcr=self.gcr, ) return pd.DataFrame({ "tracker_theta": tr["tracker_theta"], "solar_elevation": sp["apparent_elevation"], "solar_azimuth": sp["azimuth"], }, index=times)