|
9 | 9 | import logging |
10 | 10 | from abc import ABC |
11 | 11 | from collections import deque |
12 | | -from datetime import datetime |
13 | | -from math import isinf, isnan |
14 | 12 | from typing import ( |
15 | 13 | Callable, |
16 | 14 | Dict, |
17 | 15 | Generic, |
18 | 16 | List, |
19 | 17 | Optional, |
20 | | - Set, |
21 | 18 | Tuple, |
22 | 19 | Type, |
23 | 20 | TypeVar, |
|
30 | 27 | from ..._internal._asyncio import cancel_and_await |
31 | 28 | from .. import Sample, Sample3Phase |
32 | 29 | from .._quantities import Quantity, QuantityT |
| 30 | +from ._formula_evaluator import FormulaEvaluator |
33 | 31 | from ._formula_steps import ( |
34 | 32 | Adder, |
35 | 33 | Averager, |
|
56 | 54 | } |
57 | 55 |
|
58 | 56 |
|
59 | | -class FormulaEvaluator(Generic[QuantityT]): |
60 | | - """A post-fix formula evaluator that operates on `Sample` receivers.""" |
61 | | - |
62 | | - def __init__( |
63 | | - self, |
64 | | - name: str, |
65 | | - steps: List[FormulaStep], |
66 | | - metric_fetchers: Dict[str, MetricFetcher[QuantityT]], |
67 | | - create_method: Callable[[float], QuantityT], |
68 | | - ) -> None: |
69 | | - """Create a `FormulaEngine` instance. |
70 | | -
|
71 | | - Args: |
72 | | - name: A name for the formula. |
73 | | - steps: Steps for the engine to execute, in post-fix order. |
74 | | - metric_fetchers: Fetchers for each metric stream the formula depends on. |
75 | | - create_method: A method to generate the output `Sample` value with. If the |
76 | | - formula is for generating power values, this would be |
77 | | - `Power.from_watts`, for example. |
78 | | - """ |
79 | | - self._name = name |
80 | | - self._steps = steps |
81 | | - self._metric_fetchers: Dict[str, MetricFetcher[QuantityT]] = metric_fetchers |
82 | | - self._first_run = True |
83 | | - self._create_method: Callable[[float], QuantityT] = create_method |
84 | | - |
85 | | - async def _synchronize_metric_timestamps( |
86 | | - self, metrics: Set[asyncio.Task[Optional[Sample[QuantityT]]]] |
87 | | - ) -> datetime: |
88 | | - """Synchronize the metric streams. |
89 | | -
|
90 | | - For synchronised streams like data from the `ComponentMetricsResamplingActor`, |
91 | | - this a call to this function is required only once, before the first set of |
92 | | - inputs are fetched. |
93 | | -
|
94 | | - Args: |
95 | | - metrics: The finished tasks from the first `fetch_next` calls to all the |
96 | | - `MetricFetcher`s. |
97 | | -
|
98 | | - Returns: |
99 | | - The timestamp of the latest metric value. |
100 | | -
|
101 | | - Raises: |
102 | | - RuntimeError: when some streams have no value, or when the synchronization |
103 | | - of timestamps fails. |
104 | | - """ |
105 | | - metrics_by_ts: Dict[datetime, list[str]] = {} |
106 | | - for metric in metrics: |
107 | | - result = metric.result() |
108 | | - name = metric.get_name() |
109 | | - if result is None: |
110 | | - raise RuntimeError(f"Stream closed for component: {name}") |
111 | | - metrics_by_ts.setdefault(result.timestamp, []).append(name) |
112 | | - latest_ts = max(metrics_by_ts) |
113 | | - |
114 | | - # fetch the metrics with non-latest timestamps again until we have the values |
115 | | - # for the same ts for all metrics. |
116 | | - for metric_ts, names in metrics_by_ts.items(): |
117 | | - if metric_ts == latest_ts: |
118 | | - continue |
119 | | - while metric_ts < latest_ts: |
120 | | - for name in names: |
121 | | - fetcher = self._metric_fetchers[name] |
122 | | - next_val = await fetcher.fetch_next() |
123 | | - assert next_val is not None |
124 | | - metric_ts = next_val.timestamp |
125 | | - if metric_ts > latest_ts: |
126 | | - raise RuntimeError( |
127 | | - "Unable to synchronize resampled metric timestamps, " |
128 | | - f"for formula: {self._name}" |
129 | | - ) |
130 | | - self._first_run = False |
131 | | - return latest_ts |
132 | | - |
133 | | - async def apply(self) -> Sample[QuantityT]: |
134 | | - """Fetch the latest metrics, apply the formula once and return the result. |
135 | | -
|
136 | | - Returns: |
137 | | - The result of the formula. |
138 | | -
|
139 | | - Raises: |
140 | | - RuntimeError: if some samples didn't arrive, or if formula application |
141 | | - failed. |
142 | | - """ |
143 | | - eval_stack: List[float] = [] |
144 | | - ready_metrics, pending = await asyncio.wait( |
145 | | - [ |
146 | | - asyncio.create_task(fetcher.fetch_next(), name=name) |
147 | | - for name, fetcher in self._metric_fetchers.items() |
148 | | - ], |
149 | | - return_when=asyncio.ALL_COMPLETED, |
150 | | - ) |
151 | | - |
152 | | - if pending or any(res.result() is None for res in iter(ready_metrics)): |
153 | | - raise RuntimeError( |
154 | | - f"Some resampled metrics didn't arrive, for formula: {self._name}" |
155 | | - ) |
156 | | - |
157 | | - if self._first_run: |
158 | | - metric_ts = await self._synchronize_metric_timestamps(ready_metrics) |
159 | | - else: |
160 | | - sample = next(iter(ready_metrics)).result() |
161 | | - assert sample is not None |
162 | | - metric_ts = sample.timestamp |
163 | | - |
164 | | - for step in self._steps: |
165 | | - step.apply(eval_stack) |
166 | | - |
167 | | - # if all steps were applied and the formula was correct, there should only be a |
168 | | - # single value in the evaluation stack, and that would be the formula result. |
169 | | - if len(eval_stack) != 1: |
170 | | - raise RuntimeError(f"Formula application failed: {self._name}") |
171 | | - |
172 | | - res = eval_stack.pop() |
173 | | - if isnan(res) or isinf(res): |
174 | | - return Sample(metric_ts, None) |
175 | | - |
176 | | - return Sample(metric_ts, self._create_method(res)) |
177 | | - |
178 | | - |
179 | 57 | _CompositionType = Union[ |
180 | 58 | "FormulaEngine", |
181 | 59 | "HigherOrderFormulaBuilder", |
|
0 commit comments