|
6 | 6 | import enum |
7 | 7 | from collections.abc import Sequence |
8 | 8 | from dataclasses import dataclass |
| 9 | +from datetime import datetime |
| 10 | +from typing import assert_never |
| 11 | + |
| 12 | +from ._bounds import Bounds |
| 13 | +from ._metric import Metric |
9 | 14 |
|
10 | 15 |
|
11 | 16 | @enum.unique |
@@ -59,3 +64,111 @@ def __str__(self) -> str: |
59 | 64 | extra.append(f"num_raw:{len(self.raw_values)}") |
60 | 65 | extra_str = f"<{' '.join(extra)}>" if extra else "" |
61 | 66 | return f"avg:{self.avg}{extra_str}" |
| 67 | + |
| 68 | + |
| 69 | +@dataclass(frozen=True, kw_only=True) |
| 70 | +class MetricSample: |
| 71 | + """A sampled metric. |
| 72 | +
|
| 73 | + This represents a single sample of a specific metric, the value of which is either |
| 74 | + measured at a particular time. The real-time system-defined bounds are optional and |
| 75 | + may not always be present or set. |
| 76 | +
|
| 77 | + Note: Relationship Between Bounds and Metric Samples |
| 78 | + Suppose a metric sample for active power has a lower-bound of -10,000 W, and an |
| 79 | + upper-bound of 10,000 W. For the system to accept a charge command, clients need |
| 80 | + to request current values within the bounds. |
| 81 | + """ |
| 82 | + |
| 83 | + sampled_at: datetime |
| 84 | + """The moment when the metric was sampled.""" |
| 85 | + |
| 86 | + metric: Metric | int |
| 87 | + """The metric that was sampled.""" |
| 88 | + |
| 89 | + # In the protocol this is float | AggregatedMetricValue, but for live data we can't |
| 90 | + # receive the AggregatedMetricValue, so we limit this to float for now. |
| 91 | + value: float | AggregatedMetricValue | None |
| 92 | + """The value of the sampled metric.""" |
| 93 | + |
| 94 | + bounds: list[Bounds] |
| 95 | + """The bounds that apply to the metric sample. |
| 96 | +
|
| 97 | + These bounds adapt in real-time to reflect the operating conditions at the time of |
| 98 | + aggregation or derivation. |
| 99 | +
|
| 100 | + In the case of certain components like batteries, multiple bounds might exist. These |
| 101 | + multiple bounds collectively extend the range of allowable values, effectively |
| 102 | + forming a union of all given bounds. In such cases, the value of the metric must be |
| 103 | + within at least one of the bounds. |
| 104 | +
|
| 105 | + In accordance with the passive sign convention, bounds that limit discharge would |
| 106 | + have negative numbers, while those limiting charge, such as for the State of Power |
| 107 | + (SoP) metric, would be positive. Hence bounds can have positive and negative values |
| 108 | + depending on the metric they represent. |
| 109 | +
|
| 110 | + Example: |
| 111 | + The diagram below illustrates the relationship between the bounds. |
| 112 | +
|
| 113 | + ``` |
| 114 | + bound[0].lower bound[1].upper |
| 115 | + <-------|============|------------------|============|---------> |
| 116 | + bound[0].upper bound[1].lower |
| 117 | +
|
| 118 | + ---- values here are disallowed and will be rejected |
| 119 | + ==== values here are allowed and will be accepted |
| 120 | + ``` |
| 121 | + """ |
| 122 | + |
| 123 | + connection: str | None = None |
| 124 | + """The electrical connection within the component from which the metric was sampled. |
| 125 | +
|
| 126 | + This will be present when the same `Metric` can be obtained from multiple |
| 127 | + electrical connections within the component. Knowing the connection can help in |
| 128 | + certain control and monitoring applications. |
| 129 | +
|
| 130 | + In cases where the component has just one connection for a metric, then the |
| 131 | + connection is `None`. |
| 132 | +
|
| 133 | + Example: |
| 134 | + A hybrid inverter can have a DC string for a battery and another DC string for a |
| 135 | + PV array. The connection names could resemble, say, `dc_battery_0` and |
| 136 | + ``dc_pv_0`. A metric like DC voltage can be obtained from both connections. For |
| 137 | + an application to determine the SoC of the battery using the battery voltage, |
| 138 | + which connection the voltage metric was sampled from is important. |
| 139 | + """ |
| 140 | + |
| 141 | + def as_single_value( |
| 142 | + self, *, aggregation_method: AggregationMethod = AggregationMethod.AVG |
| 143 | + ) -> float | None: |
| 144 | + """Return the value of this sample as a single value. |
| 145 | +
|
| 146 | + if [`value`][frequenz.client.microgrid.metrics.MetricSample.value] is a `float`, |
| 147 | + it is returned as is. If `value` is an |
| 148 | + [`AggregatedMetricValue`][frequenz.client.microgrid.metrics.AggregatedMetricValue], |
| 149 | + the value is aggregated using the provided `aggregation_method`. |
| 150 | +
|
| 151 | + Args: |
| 152 | + aggregation_method: The method to use to aggregate the value when `value` is |
| 153 | + a `AggregatedMetricValue`. |
| 154 | +
|
| 155 | + Returns: |
| 156 | + The value of the sample as a single value, or `None` if the value is `None`. |
| 157 | + """ |
| 158 | + match self.value: |
| 159 | + case float() | int(): |
| 160 | + return self.value |
| 161 | + case AggregatedMetricValue(): |
| 162 | + match aggregation_method: |
| 163 | + case AggregationMethod.AVG: |
| 164 | + return self.value.avg |
| 165 | + case AggregationMethod.MIN: |
| 166 | + return self.value.min |
| 167 | + case AggregationMethod.MAX: |
| 168 | + return self.value.max |
| 169 | + case unexpected: |
| 170 | + assert_never(unexpected) |
| 171 | + case None: |
| 172 | + return None |
| 173 | + case unexpected: |
| 174 | + assert_never(unexpected) |
0 commit comments