|
5 | 5 |
|
6 | 6 |
|
7 | 7 | from dataclasses import dataclass |
8 | | -from datetime import datetime, timedelta |
9 | | -from enum import IntEnum |
| 8 | +from datetime import datetime, timedelta, timezone |
| 9 | +from enum import Enum, IntEnum |
10 | 10 | from typing import Any, cast |
11 | 11 |
|
12 | 12 | # pylint: disable=no-name-in-module |
|
27 | 27 | # pylint: enable=no-name-in-module |
28 | 28 | from frequenz.client.common.microgrid.components import ComponentCategory |
29 | 29 |
|
30 | | -from .recurrence import RecurrenceRule |
| 30 | +from .recurrence import Frequency, RecurrenceRule, Weekday |
31 | 31 |
|
32 | 32 | ComponentSelector = list[int] | list[ComponentCategory] |
33 | 33 | """A component selector specifying which components a dispatch targets. |
@@ -115,6 +115,19 @@ class TimeIntervalFilter: |
115 | 115 | """Filter by end_time < end_to.""" |
116 | 116 |
|
117 | 117 |
|
| 118 | +class RunningState(Enum): |
| 119 | + """The running state of a dispatch.""" |
| 120 | + |
| 121 | + RUNNING = "RUNNING" |
| 122 | + """The dispatch is running.""" |
| 123 | + |
| 124 | + STOPPED = "STOPPED" |
| 125 | + """The dispatch is stopped.""" |
| 126 | + |
| 127 | + DIFFERENT_TYPE = "DIFFERENT_TYPE" |
| 128 | + """The dispatch is for a different type.""" |
| 129 | + |
| 130 | + |
118 | 131 | @dataclass(kw_only=True, frozen=True) |
119 | 132 | class Dispatch: # pylint: disable=too-many-instance-attributes |
120 | 133 | """Represents a dispatch operation within a microgrid system.""" |
@@ -160,6 +173,122 @@ class Dispatch: # pylint: disable=too-many-instance-attributes |
160 | 173 | update_time: datetime |
161 | 174 | """The last update time of the dispatch in UTC. Set when a dispatch is modified.""" |
162 | 175 |
|
| 176 | + def running(self, type_: str) -> RunningState: |
| 177 | + """Check if the dispatch is currently supposed to be running. |
| 178 | +
|
| 179 | + Args: |
| 180 | + type_: The type of the dispatch that should be running. |
| 181 | +
|
| 182 | + Returns: |
| 183 | + RUNNING if the dispatch is running, |
| 184 | + STOPPED if it is stopped, |
| 185 | + DIFFERENT_TYPE if it is for a different type. |
| 186 | + """ |
| 187 | + if self.type != type_: |
| 188 | + return RunningState.DIFFERENT_TYPE |
| 189 | + |
| 190 | + if not self.active: |
| 191 | + return RunningState.STOPPED |
| 192 | + |
| 193 | + now = datetime.now(tz=timezone.utc) |
| 194 | + |
| 195 | + if now < self.start_time: |
| 196 | + return RunningState.STOPPED |
| 197 | + |
| 198 | + # A dispatch without duration is always running, once it started |
| 199 | + if self.duration is None: |
| 200 | + return RunningState.RUNNING |
| 201 | + |
| 202 | + if until := self._until(now): |
| 203 | + return RunningState.RUNNING if now < until else RunningState.STOPPED |
| 204 | + |
| 205 | + return RunningState.STOPPED |
| 206 | + |
| 207 | + @property |
| 208 | + def until(self) -> datetime | None: |
| 209 | + """Time when the dispatch should end. |
| 210 | +
|
| 211 | + Returns the time that a running dispatch should end. |
| 212 | + If the dispatch is not running, None is returned. |
| 213 | +
|
| 214 | + Returns: |
| 215 | + The time when the dispatch should end or None if the dispatch is not running. |
| 216 | + """ |
| 217 | + if not self.active: |
| 218 | + return None |
| 219 | + |
| 220 | + now = datetime.now(tz=timezone.utc) |
| 221 | + return self._until(now) |
| 222 | + |
| 223 | + @property |
| 224 | + def next_run(self) -> datetime | None: |
| 225 | + """Calculate the next run of a dispatch. |
| 226 | +
|
| 227 | + Returns: |
| 228 | + The next run of the dispatch or None if the dispatch is finished. |
| 229 | + """ |
| 230 | + return self.next_run_after(datetime.now(tz=timezone.utc)) |
| 231 | + |
| 232 | + def next_run_after(self, after: datetime) -> datetime | None: |
| 233 | + """Calculate the next run of a dispatch. |
| 234 | +
|
| 235 | + Args: |
| 236 | + after: The time to calculate the next run from. |
| 237 | +
|
| 238 | + Returns: |
| 239 | + The next run of the dispatch or None if the dispatch is finished. |
| 240 | + """ |
| 241 | + if ( |
| 242 | + not self.recurrence.frequency |
| 243 | + or self.recurrence.frequency == Frequency.UNSPECIFIED |
| 244 | + or self.duration is None # Infinite duration |
| 245 | + ): |
| 246 | + if after > self.start_time: |
| 247 | + return None |
| 248 | + return self.start_time |
| 249 | + |
| 250 | + # Make sure no weekday is UNSPECIFIED |
| 251 | + if Weekday.UNSPECIFIED in self.recurrence.byweekdays: |
| 252 | + return None |
| 253 | + |
| 254 | + # No type information for rrule, so we need to cast |
| 255 | + return cast( |
| 256 | + datetime | None, |
| 257 | + self.recurrence.prepare(self.start_time).after(after, inc=True), |
| 258 | + ) |
| 259 | + |
| 260 | + def _until(self, now: datetime) -> datetime | None: |
| 261 | + """Calculate the time when the dispatch should end. |
| 262 | +
|
| 263 | + If no previous run is found, None is returned. |
| 264 | +
|
| 265 | + Args: |
| 266 | + now: The current time. |
| 267 | +
|
| 268 | + Returns: |
| 269 | + The time when the dispatch should end or None if the dispatch is not running. |
| 270 | +
|
| 271 | + Raises: |
| 272 | + ValueError: If the dispatch has no duration. |
| 273 | + """ |
| 274 | + if self.duration is None: |
| 275 | + raise ValueError("_until: Dispatch has no duration") |
| 276 | + |
| 277 | + if ( |
| 278 | + not self.recurrence.frequency |
| 279 | + or self.recurrence.frequency == Frequency.UNSPECIFIED |
| 280 | + ): |
| 281 | + return self.start_time + self.duration |
| 282 | + |
| 283 | + latest_past_start: datetime | None = self.recurrence.prepare( |
| 284 | + self.start_time |
| 285 | + ).before(now, inc=True) |
| 286 | + |
| 287 | + if not latest_past_start: |
| 288 | + return None |
| 289 | + |
| 290 | + return latest_past_start + self.duration |
| 291 | + |
163 | 292 | @classmethod |
164 | 293 | def from_protobuf(cls, pb_object: PBDispatch) -> "Dispatch": |
165 | 294 | """Convert a protobuf dispatch to a dispatch. |
|
0 commit comments