File size: 7,896 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
"""
OperationalModes: weather protection, heat shield, and harvest mode.

These override normal engine output at the P1/P2 priority level
in the CommandArbiter.  Each mode returns a dict that the arbiter
recognises as ``weather_override`` or ``harvest_active``.

Modes
-----
- **WindStow**: panels go flat (0°) when wind exceeds threshold.
- **HailStow**: panels go flat (0°) during hail events.
- **HeatShield**: maximum shading offset regardless of budget
  when air temperature AND CWSI exceed emergency thresholds.
- **HarvestMode**: panels park vertical (90°) for machine clearance.
  Activated/deactivated manually or by schedule.
"""

from __future__ import annotations

import logging
from dataclasses import dataclass
from datetime import date, datetime, time, timezone
from typing import Optional

from config.settings import (
    HEAT_SHIELD_CWSI,
    HEAT_SHIELD_TEMP_C,
    WIND_STOW_SPEED_MS,
)

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Mode results
# ---------------------------------------------------------------------------

@dataclass
class ModeOverride:
    """Result from an operational mode check."""

    active: bool
    mode: str               # "wind_stow" | "hail_stow" | "heat_shield" | "harvest"
    target_angle: float     # angle to command
    reason: str = ""
    bypass_budget: bool = False  # True → ignore energy budget
    bypass_hysteresis: bool = False  # True → skip hysteresis filter

    def to_weather_override(self) -> Optional[dict]:
        """Convert to the dict format CommandArbiter.arbitrate() expects."""
        if not self.active:
            return None
        return {
            "target_angle": self.target_angle,
            "reason": self.reason,
        }


# ---------------------------------------------------------------------------
# Individual mode checkers
# ---------------------------------------------------------------------------

def check_wind_stow(
    wind_speed_ms: float,
    threshold: float = WIND_STOW_SPEED_MS,
) -> ModeOverride:
    """Flat stow if wind exceeds threshold."""
    if wind_speed_ms >= threshold:
        return ModeOverride(
            active=True,
            mode="wind_stow",
            target_angle=0.0,
            reason=f"wind stow: {wind_speed_ms:.1f} m/s >= {threshold:.0f} m/s",
            bypass_budget=True,
            bypass_hysteresis=True,
        )
    return ModeOverride(active=False, mode="wind_stow", target_angle=0.0)


def check_hail_stow(hail_detected: bool) -> ModeOverride:
    """Flat stow during hail."""
    if hail_detected:
        return ModeOverride(
            active=True,
            mode="hail_stow",
            target_angle=0.0,
            reason="hail detected — flat stow",
            bypass_budget=True,
            bypass_hysteresis=True,
        )
    return ModeOverride(active=False, mode="hail_stow", target_angle=0.0)


def check_heat_shield(
    air_temp_c: float,
    cwsi: Optional[float] = None,
    temp_threshold: float = HEAT_SHIELD_TEMP_C,
    cwsi_threshold: float = HEAT_SHIELD_CWSI,
    max_offset_deg: float = 20.0,
    theta_astro: float = 0.0,
) -> ModeOverride:
    """Emergency heat shield: maximum shade offset regardless of budget.

    Activates when BOTH air temperature AND CWSI exceed their thresholds.
    If CWSI is unavailable, activates on temperature alone at +2°C above threshold.
    """
    temp_exceeded = air_temp_c >= temp_threshold

    if cwsi is not None:
        cwsi_exceeded = cwsi >= cwsi_threshold
        activate = temp_exceeded and cwsi_exceeded
        reason = (f"heat shield: T={air_temp_c:.1f}°C >= {temp_threshold:.0f}°C, "
                  f"CWSI={cwsi:.2f} >= {cwsi_threshold:.2f}")
    else:
        # Without CWSI, require a higher temperature
        activate = air_temp_c >= temp_threshold + 2.0
        reason = (f"heat shield (no CWSI): T={air_temp_c:.1f}°C >= "
                  f"{temp_threshold + 2.0:.0f}°C")

    if activate:
        return ModeOverride(
            active=True,
            mode="heat_shield",
            target_angle=theta_astro + max_offset_deg,
            reason=reason,
            bypass_budget=True,
            bypass_hysteresis=False,
        )
    return ModeOverride(active=False, mode="heat_shield", target_angle=0.0)


# ---------------------------------------------------------------------------
# Harvest mode
# ---------------------------------------------------------------------------

class HarvestMode:
    """Manages harvest parking state.

    Harvest mode is a manual toggle (operator activates it before
    sending machines into the vineyard).  Can also be scheduled.
    """

    def __init__(self):
        self._active = False
        self._scheduled_dates: list[date] = []

    def activate(self) -> None:
        self._active = True
        logger.info("Harvest mode ACTIVATED — panels will park vertical")

    def deactivate(self) -> None:
        self._active = False
        logger.info("Harvest mode DEACTIVATED — normal control resumed")

    def set_schedule(self, dates: list[date]) -> None:
        self._scheduled_dates = sorted(dates)
        logger.info("Harvest schedule set: %s", [str(d) for d in dates])

    def check(self, current_date: Optional[date] = None) -> ModeOverride:
        today = current_date or date.today()
        active = self._active or today in self._scheduled_dates
        if active:
            return ModeOverride(
                active=True,
                mode="harvest",
                target_angle=90.0,
                reason="harvest mode — panels parked vertical for machine clearance",
                bypass_budget=True,
                bypass_hysteresis=True,
            )
        return ModeOverride(active=False, mode="harvest", target_angle=0.0)


# ---------------------------------------------------------------------------
# Composite checker
# ---------------------------------------------------------------------------

class OperationalModeChecker:
    """Run all mode checks in priority order.

    Returns the highest-priority active mode, or None if all clear.

    Priority: wind_stow > hail_stow > harvest > heat_shield
    """

    def __init__(self):
        self.harvest = HarvestMode()

    def check_all(
        self,
        wind_speed_ms: Optional[float] = None,
        hail_detected: bool = False,
        air_temp_c: Optional[float] = None,
        cwsi: Optional[float] = None,
        theta_astro: float = 0.0,
        current_date: Optional[date] = None,
    ) -> Optional[ModeOverride]:
        """Check all operational modes in priority order.

        Returns the first active override, or None.
        """
        # P1a: Wind stow
        if wind_speed_ms is not None:
            result = check_wind_stow(wind_speed_ms)
            if result.active:
                logger.warning("Mode override: %s", result.reason)
                return result

        # P1b: Hail stow
        if hail_detected:
            result = check_hail_stow(True)
            if result.active:
                logger.warning("Mode override: %s", result.reason)
                return result

        # P2: Harvest
        harvest_result = self.harvest.check(current_date)
        if harvest_result.active:
            logger.info("Mode override: %s", harvest_result.reason)
            return harvest_result

        # Heat shield (lower priority than harvest — don't shade
        # while machines are in the vineyard)
        if air_temp_c is not None:
            result = check_heat_shield(
                air_temp_c=air_temp_c,
                cwsi=cwsi,
                theta_astro=theta_astro,
            )
            if result.active:
                logger.warning("Mode override: %s", result.reason)
                return result

        return None