| """ |
| 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. |
| """ |
|
|
| |
| |
| |
| 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: |
| |
| 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) |
|
|