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