api / src /pvlib_tracker.py
Eli Safra
Deploy SolarWine API (FastAPI + Docker, port 7860)
938949f
"""
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)