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)