|
5 | 5 |
|
6 | 6 | from collections import namedtuple
|
7 | 7 | from datetime import datetime, timedelta
|
| 8 | +from itertools import groupby |
| 9 | +from typing import Any |
8 | 10 |
|
9 | 11 | from frequenz.client.common.metric import Metric
|
10 | 12 | from frequenz.client.reporting import ReportingApiClient
|
| 13 | +from frequenz.client.reporting._client import MetricSample |
11 | 14 |
|
12 | 15 | CumulativeEnergy = namedtuple(
|
13 | 16 | "CumulativeEnergy", ["start_time", "end_time", "consumption", "production"]
|
@@ -122,3 +125,218 @@ async def cumulative_energy(
|
122 | 125 | consumption=consumption,
|
123 | 126 | production=production,
|
124 | 127 | )
|
| 128 | + |
| 129 | + |
| 130 | +# pylint: disable-next=too-many-arguments |
| 131 | +async def fetch_and_extract_state_durations( |
| 132 | + *, |
| 133 | + client: ReportingApiClient, |
| 134 | + microgrid_components: list[tuple[int, list[int]]], |
| 135 | + metrics: list[Metric], |
| 136 | + start_time: datetime, |
| 137 | + end_time: datetime, |
| 138 | + resampling_period: timedelta | None, |
| 139 | + alert_states: list[int], |
| 140 | + include_warnings: bool = True, |
| 141 | +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: |
| 142 | + """Fetch data using the Reporting API and extract state durations and alert records. |
| 143 | +
|
| 144 | + Args: |
| 145 | + client: The client used to fetch the metric samples from the Reporting API. |
| 146 | + microgrid_components: List of tuples where each tuple contains microgrid |
| 147 | + ID and corresponding component IDs. |
| 148 | + metrics: List of metric names. |
| 149 | + NOTE: The service will support requesting states without metrics in |
| 150 | + the future and this argument will be removed. |
| 151 | + start_time: The start date and time for the period. |
| 152 | + end_time: The end date and time for the period. |
| 153 | + resampling_period: The period for resampling the data. If None, data |
| 154 | + will be returned in its original resolution |
| 155 | + alert_states: List of component state values that should trigger an alert. |
| 156 | + include_warnings: Whether to include warning state values in the alert |
| 157 | + records. |
| 158 | +
|
| 159 | + Returns: |
| 160 | + A tuple containing two lists: |
| 161 | + - all_states: Contains all state records including start and end times. |
| 162 | + - alert_records: Contains filtered records matching the alert criteria. |
| 163 | + """ |
| 164 | + samples = await _fetch_component_data( |
| 165 | + client=client, |
| 166 | + microgrid_components=microgrid_components, |
| 167 | + metrics=metrics, |
| 168 | + start_time=start_time, |
| 169 | + end_time=end_time, |
| 170 | + resampling_period=resampling_period, |
| 171 | + include_states=True, |
| 172 | + include_bounds=False, |
| 173 | + ) |
| 174 | + |
| 175 | + all_states, alert_records = extract_state_durations( |
| 176 | + samples, alert_states, include_warnings |
| 177 | + ) |
| 178 | + return all_states, alert_records |
| 179 | + |
| 180 | + |
| 181 | +def extract_state_durations( |
| 182 | + samples: list[MetricSample], |
| 183 | + alert_states: list[int], |
| 184 | + include_warnings: bool = True, |
| 185 | +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: |
| 186 | + """ |
| 187 | + Extract state durations and alert records based on state transitions. |
| 188 | +
|
| 189 | + Args: |
| 190 | + samples: List of MetricSample instances containing the reporting data. |
| 191 | + alert_states: List of component state values that should trigger an alert. |
| 192 | + Component error codes are reported by default. |
| 193 | + include_warnings: Whether to include warning state values in the alert records. |
| 194 | +
|
| 195 | + Returns: |
| 196 | + A tuple containing two lists: |
| 197 | + - all_states: Contains all state records including start and end times. |
| 198 | + - alert_records: Contains filtered records matching the alert criteria. |
| 199 | + """ |
| 200 | + alert_metrics = ["warning", "error"] if include_warnings else ["error"] |
| 201 | + state_metrics = ["state"] + alert_metrics |
| 202 | + filtered_samples = sorted( |
| 203 | + (s for s in samples if s.metric in state_metrics), |
| 204 | + key=lambda s: (s.microgrid_id, s.component_id, s.metric, s.timestamp), |
| 205 | + ) |
| 206 | + |
| 207 | + if not filtered_samples: |
| 208 | + return [], [] |
| 209 | + |
| 210 | + # Group samples by (microgrid_id, component_id, metric) |
| 211 | + all_states = [] |
| 212 | + for key, group in groupby( |
| 213 | + filtered_samples, key=lambda s: (s.microgrid_id, s.component_id, s.metric) |
| 214 | + ): |
| 215 | + states = _process_group_samples(key, list(group)) |
| 216 | + all_states.extend(states) |
| 217 | + |
| 218 | + all_states.sort( |
| 219 | + key=lambda x: (x["microgrid_id"], x["component_id"], x["start_time"]) |
| 220 | + ) |
| 221 | + |
| 222 | + alert_records = _filter_alerts(all_states, alert_states, alert_metrics) |
| 223 | + return all_states, alert_records |
| 224 | + |
| 225 | + |
| 226 | +def _process_group_samples( |
| 227 | + key: tuple[int, int, str], |
| 228 | + group_samples: list["MetricSample"], |
| 229 | +) -> list[dict[str, Any]]: |
| 230 | + """Process samples for a single group to extract state durations. |
| 231 | +
|
| 232 | + Args: |
| 233 | + key: Tuple containing microgrid ID, component ID, and metric. |
| 234 | + group_samples: List of samples for the group. |
| 235 | +
|
| 236 | + Returns: |
| 237 | + List of state records. |
| 238 | + """ |
| 239 | + mid, cid, metric = key |
| 240 | + state_records = [] |
| 241 | + current_state_value = None |
| 242 | + start_time = None |
| 243 | + |
| 244 | + for sample in group_samples: |
| 245 | + if current_state_value != sample.value: |
| 246 | + # Close previous state run |
| 247 | + if current_state_value is not None: |
| 248 | + state_records.append( |
| 249 | + { |
| 250 | + "microgrid_id": mid, |
| 251 | + "component_id": cid, |
| 252 | + "state_type": metric, |
| 253 | + "state_value": current_state_value, |
| 254 | + "start_time": start_time, |
| 255 | + "end_time": sample.timestamp, |
| 256 | + } |
| 257 | + ) |
| 258 | + # Start new state run |
| 259 | + current_state_value = sample.value |
| 260 | + start_time = sample.timestamp |
| 261 | + |
| 262 | + # Close the last state run |
| 263 | + state_records.append( |
| 264 | + { |
| 265 | + "microgrid_id": mid, |
| 266 | + "component_id": cid, |
| 267 | + "state_type": metric, |
| 268 | + "state_value": current_state_value, |
| 269 | + "start_time": start_time, |
| 270 | + "end_time": None, |
| 271 | + } |
| 272 | + ) |
| 273 | + |
| 274 | + return state_records |
| 275 | + |
| 276 | + |
| 277 | +def _filter_alerts( |
| 278 | + all_states: list[dict[str, Any]], |
| 279 | + alert_states: list[int], |
| 280 | + alert_metrics: list[str], |
| 281 | +) -> list[dict[str, Any]]: |
| 282 | + """Identify alert records from all states. |
| 283 | +
|
| 284 | + Args: |
| 285 | + all_states: List of all state records. |
| 286 | + alert_states: List of component state values that should trigger an alert. |
| 287 | + alert_metrics: List of metric names that should trigger an alert. |
| 288 | +
|
| 289 | + Returns: |
| 290 | + List of alert records. |
| 291 | + """ |
| 292 | + return [ |
| 293 | + state |
| 294 | + for state in all_states |
| 295 | + if ( |
| 296 | + (state["state_type"] == "state" and state["state_value"] in alert_states) |
| 297 | + or (state["state_type"] in alert_metrics) |
| 298 | + ) |
| 299 | + ] |
| 300 | + |
| 301 | + |
| 302 | +# pylint: disable-next=too-many-arguments |
| 303 | +async def _fetch_component_data( |
| 304 | + *, |
| 305 | + client: ReportingApiClient, |
| 306 | + microgrid_components: list[tuple[int, list[int]]], |
| 307 | + metrics: list[Metric], |
| 308 | + start_time: datetime, |
| 309 | + end_time: datetime, |
| 310 | + resampling_period: timedelta | None, |
| 311 | + include_states: bool = False, |
| 312 | + include_bounds: bool = False, |
| 313 | +) -> list[MetricSample]: |
| 314 | + """Fetch component data from the Reporting API. |
| 315 | +
|
| 316 | + Args: |
| 317 | + client: The client used to fetch the metric samples from the Reporting API. |
| 318 | + microgrid_components: List of tuples where each tuple contains |
| 319 | + microgrid ID and corresponding component IDs. |
| 320 | + metrics: List of metric names. |
| 321 | + start_time: The start date and time for the period. |
| 322 | + end_time: The end date and time for the period. |
| 323 | + resampling_period: The period for resampling the data. If None, data |
| 324 | + will be returned in its original resolution |
| 325 | + include_states: Whether to include the state data. |
| 326 | + include_bounds: Whether to include the bound data. |
| 327 | +
|
| 328 | + Returns: |
| 329 | + List of MetricSample instances containing the reporting data. |
| 330 | + """ |
| 331 | + return [ |
| 332 | + sample |
| 333 | + async for sample in client.list_microgrid_components_data( |
| 334 | + microgrid_components=microgrid_components, |
| 335 | + metrics=metrics, |
| 336 | + start_dt=start_time, |
| 337 | + end_dt=end_time, |
| 338 | + resampling_period=resampling_period, |
| 339 | + include_states=include_states, |
| 340 | + include_bounds=include_bounds, |
| 341 | + ) |
| 342 | + ] |
0 commit comments