File size: 11,826 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
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
"""
CommandArbiter: priority stack, hysteresis, and fallback logic for tracker commands.

Sits between the TradeoffEngine output and the physical tracker actuator.
Ensures:
  1. Weather protection and harvest mode override everything.
  2. Safety rail alerts and simulation timeouts fall back to θ_astro.
  3. Hysteresis prevents sub-slot jitter (motor protection).
  4. All fallbacks default to full astronomical tracking (zero energy cost).

Priority Stack (highest to lowest):
  P1  Weather Protection  → stow angle (flat, 0°)
  P2  Mechanical Harvest  → vertical park (90°)
  P3  Safety Rail Alert   → θ_astro
  P4  Simulation Timeout  → θ_astro
  P5  TradeoffEngine      → θ_astro or θ_astro + offset
"""

from __future__ import annotations

from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from typing import Optional

import pandas as pd

from config.settings import (
    ANGLE_TOLERANCE_DEG,
    HYSTERESIS_WINDOW_MIN,
    SIMULATION_TIMEOUT_SEC,
    WIND_STOW_SPEED_MS,
)


class CommandSource(str, Enum):
    """Priority source identifiers for tracker commands."""
    WEATHER = "weather_protection"
    HARVEST = "harvest_mode"
    SAFETY = "safety_fallback"
    TIMEOUT = "timeout_fallback"
    ENGINE = "engine"
    HYSTERESIS = "hysteresis"
    INITIAL = "initial"
    STABLE = "stable"


@dataclass
class ArbiterDecision:
    """Output of the CommandArbiter."""

    angle: float                    # final tracker tilt angle (degrees)
    dispatch: bool                  # True = send command to actuator
    source: str                     # which priority level decided
    requested_angle: float = 0.0    # what was originally requested
    suppressed_reason: Optional[str] = None  # why dispatch=False (if suppressed)

    def decision_tags(self) -> list[str]:
        tags = [f"source:{self.source}"]
        if not self.dispatch and self.suppressed_reason:
            tags.append(f"suppressed:{self.suppressed_reason}")
        return tags


class CommandArbiter:
    """Priority stack + hysteresis for tracker tilt commands.

    Parameters
    ----------
    hysteresis_window_min : float
        Minimum time (minutes) between consecutive tilt changes.
    angle_tolerance_deg : float
        Changes smaller than this are suppressed (motor protection).
    """

    def __init__(
        self,
        hysteresis_window_min: float = HYSTERESIS_WINDOW_MIN,
        angle_tolerance_deg: float = ANGLE_TOLERANCE_DEG,
    ):
        self.window_min = hysteresis_window_min
        self.tolerance = angle_tolerance_deg
        self._buffer: list[tuple[datetime, float]] = []
        self.current_angle: float = 0.0
        self._last_dispatch_time: Optional[datetime] = None

    # ------------------------------------------------------------------
    # Priority selection
    # ------------------------------------------------------------------

    def select_source(
        self,
        engine_result: dict,
        safety_valid: bool = True,
        sim_time_sec: float = 0.0,
        weather_override: Optional[dict] = None,
        harvest_active: bool = False,
        theta_astro: float = 0.0,
    ) -> dict:
        """Select the highest-priority command source.

        Parameters
        ----------
        engine_result : dict
            Output from TradeoffEngine.evaluate_slot() or find_minimum_dose().
            Must contain 'angle' key (or 'chosen_offset_deg' for DoseResult).
        safety_valid : bool
            False if SafetyRails detected FvCB/ML divergence.
        sim_time_sec : float
            Wall-clock time the simulation took (seconds).
        weather_override : dict or None
            If not None, must contain 'target_angle' and optionally 'reason'.
        harvest_active : bool
            True if mechanical harvesting is in progress.
        theta_astro : float
            Astronomical tracking angle (safe default).

        Returns
        -------
        dict with 'angle', 'source', 'reason'
        """
        # P1: Weather protection (wind stow, hail, etc.)
        if weather_override is not None:
            return {
                "angle": weather_override.get("target_angle", 0.0),
                "source": CommandSource.WEATHER,
                "reason": weather_override.get("reason", "weather override active"),
            }

        # P2: Mechanical harvesting — panels go vertical for clearance
        if harvest_active:
            return {
                "angle": 90.0,
                "source": CommandSource.HARVEST,
                "reason": "mechanical harvesting in progress",
            }

        # P3: Safety rail alert — FvCB/ML divergence too high
        if not safety_valid:
            return {
                "angle": theta_astro,
                "source": CommandSource.SAFETY,
                "reason": "FvCB/ML divergence exceeded threshold; reverting to astronomical",
            }

        # P4: Simulation timeout — shadow model took too long
        if sim_time_sec > SIMULATION_TIMEOUT_SEC:
            return {
                "angle": theta_astro,
                "source": CommandSource.TIMEOUT,
                "reason": f"simulation took {sim_time_sec:.1f}s > {SIMULATION_TIMEOUT_SEC}s limit",
            }

        # P5: Normal — use TradeoffEngine result
        angle = engine_result.get("angle", theta_astro)
        return {
            "angle": angle,
            "source": CommandSource.ENGINE,
            "reason": engine_result.get("action", "tradeoff_engine"),
        }

    # ------------------------------------------------------------------
    # Hysteresis filter
    # ------------------------------------------------------------------

    def should_move(
        self,
        requested_angle: float,
        timestamp: datetime,
    ) -> ArbiterDecision:
        """Apply hysteresis filter to a requested angle change.

        Motor protection logic:
        - Suppresses changes smaller than angle_tolerance_deg.
        - Requires the requested angle to be stable for hysteresis_window_min
          before dispatching.
        - Immediate dispatch if this is the first command or if the change
          is large (e.g., weather stow).
        """
        # Record request in buffer
        self._buffer.append((timestamp, requested_angle))

        # Trim buffer to window
        cutoff = timestamp - pd.Timedelta(minutes=self.window_min)
        self._buffer = [(t, a) for t, a in self._buffer if t >= cutoff]

        # Change smaller than tolerance → suppress
        angle_diff = abs(requested_angle - self.current_angle)
        if angle_diff <= self.tolerance:
            return ArbiterDecision(
                angle=self.current_angle,
                dispatch=False,
                source=CommandSource.HYSTERESIS,
                requested_angle=requested_angle,
                suppressed_reason=f"change {angle_diff:.1f}° ≤ tolerance {self.tolerance}°",
            )

        # First command or only one entry in buffer → dispatch immediately
        if len(self._buffer) < 2 or self._last_dispatch_time is None:
            self.current_angle = requested_angle
            self._last_dispatch_time = timestamp
            return ArbiterDecision(
                angle=requested_angle,
                dispatch=True,
                source=CommandSource.INITIAL,
                requested_angle=requested_angle,
            )

        # Check stability: all recent entries must agree within tolerance
        stable = all(
            abs(a - requested_angle) <= self.tolerance
            for _, a in self._buffer
        )

        if stable:
            self.current_angle = requested_angle
            self._last_dispatch_time = timestamp
            return ArbiterDecision(
                angle=requested_angle,
                dispatch=True,
                source=CommandSource.STABLE,
                requested_angle=requested_angle,
            )

        return ArbiterDecision(
            angle=self.current_angle,
            dispatch=False,
            source=CommandSource.HYSTERESIS,
            requested_angle=requested_angle,
            suppressed_reason="angle not stable within hysteresis window",
        )

    # ------------------------------------------------------------------
    # Combined: select + filter
    # ------------------------------------------------------------------

    def arbitrate(
        self,
        timestamp: datetime,
        engine_result: dict,
        theta_astro: float,
        safety_valid: bool = True,
        sim_time_sec: float = 0.0,
        weather_override: Optional[dict] = None,
        harvest_active: bool = False,
    ) -> ArbiterDecision:
        """Full arbitration: priority selection → hysteresis filter.

        This is the main entry point for the 15-min control loop.
        """
        selected = self.select_source(
            engine_result=engine_result,
            safety_valid=safety_valid,
            sim_time_sec=sim_time_sec,
            weather_override=weather_override,
            harvest_active=harvest_active,
            theta_astro=theta_astro,
        )

        # Weather and harvest overrides bypass hysteresis (safety-critical)
        if selected["source"] in {CommandSource.WEATHER, CommandSource.HARVEST}:
            self.current_angle = selected["angle"]
            self._last_dispatch_time = timestamp
            self._buffer.clear()
            return ArbiterDecision(
                angle=selected["angle"],
                dispatch=True,
                source=selected["source"],
                requested_angle=selected["angle"],
            )

        # Normal path: apply hysteresis
        decision = self.should_move(selected["angle"], timestamp)
        # Override source with the priority level that selected the angle
        if decision.dispatch:
            decision.source = selected["source"]
        return decision

    # ------------------------------------------------------------------
    # Wind stow helper (delegates to operational_modes)
    # ------------------------------------------------------------------

    @staticmethod
    def check_wind_stow(
        wind_speed_ms: float,
        stow_threshold: float = WIND_STOW_SPEED_MS,
    ) -> Optional[dict]:
        """Return a weather override dict if wind speed exceeds stow threshold.

        Note: ControlLoop uses OperationalModeChecker instead of this method.
        Kept for backward compatibility with direct arbiter usage.
        """
        from src.operational_modes import check_wind_stow as _check
        result = _check(wind_speed_ms, stow_threshold)
        return result.to_weather_override()


class AstronomicalTracker:
    """Pure sun-following. The always-safe default.

    Wraps ShadowModel to provide a simple get_angle(timestamp) interface.
    """

    def __init__(self, shadow_model=None):
        self._shadow_model = shadow_model

    @property
    def shadow_model(self):
        if self._shadow_model is None:
            from src.shading.solar_geometry import ShadowModel
            self._shadow_model = ShadowModel()
        return self._shadow_model

    def get_angle(self, timestamp: datetime) -> float:
        """Return the astronomical tracking angle for a given timestamp."""
        ts = pd.Timestamp(timestamp)
        if ts.tzinfo is None:
            ts = ts.tz_localize("UTC")
        sp = self.shadow_model.get_solar_position(
            pd.DatetimeIndex([ts])
        )
        elev = float(sp["solar_elevation"].iloc[0])
        if elev <= 0:
            return 0.0
        azim = float(sp["solar_azimuth"].iloc[0])
        result = self.shadow_model.compute_tracker_tilt(azim, elev)
        return float(result["tracker_theta"])