File size: 6,534 Bytes
938949f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 | """
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)
|