feat(MLRsearch): MLRsearch v7
[csit.git] / resources / libraries / python / MLRsearch / trial_measurement / measurement_result.py
1 # Copyright (c) 2023 Cisco and/or its affiliates.
2 # Licensed under the Apache License, Version 2.0 (the "License");
3 # you may not use this file except in compliance with the License.
4 # You may obtain a copy of the License at:
5 #
6 #     http://www.apache.org/licenses/LICENSE-2.0
7 #
8 # Unless required by applicable law or agreed to in writing, software
9 # distributed under the License is distributed on an "AS IS" BASIS,
10 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11 # See the License for the specific language governing permissions and
12 # limitations under the License.
13
14 """Module defining MeasurementResult class."""
15
16 from dataclasses import dataclass
17
18
19 @dataclass
20 class MeasurementResult:
21     """Structure defining the result of a single trial measurement.
22
23     There are few primary (required) quantities. Various secondary (derived)
24     quantities are calculated and can be queried.
25
26     The constructor allows broader argument types,
27     the post init function converts to the stricter types.
28
29     Integer quantities (counts) are preferred, as float values
30     can suffer from rounding errors, and sometimes they are measured
31     at unknown (possibly very limited) precision and accuracy.
32
33     There are relations between the counts (e.g. offered count
34     should be equal to a sum of forwarding count and loss count).
35     This implementation does not perform consistency checks, but uses them
36     for computing quantities the caller left unspecified.
37
38     In some cases, the units of intended load are different from units
39     of loss count (e.g. load in transactions but loss in packets).
40     Quantities with relative_ prefix can be used to get load candidates
41     from forwarding results.
42
43     Sometimes, the measurement provider is unable to reach the intended load,
44     and it can react by spending longer than intended duration
45     to reach its intended count. To signal irregular situations like this,
46     several optional fields can be given, and various secondary quantities
47     are populated, so the measurement consumer can query the quantity
48     it wants to rely on in these irregular situations.
49
50     The current implementation intentionally limits the secondary quantities
51     to the few that proved useful in practice.
52     """
53
54     # Required primary quantities.
55     intended_duration: float
56     """Intended trial measurement duration [s]."""
57     intended_load: float
58     """Intended load [tps]. If bidirectional (or multi-port) traffic is used,
59     most users will put unidirectional (single-port) value here,
60     as bandwidth and pps limits are usually per-port."""
61     # Two of the next three primary quantities are required.
62     offered_count: int = None
63     """Number of packets actually transmitted (transactions attempted).
64     This should be the aggregate (bidirectional, multi-port) value,
65     so that asymmetric trafic profiles are supported."""
66     loss_count: int = None
67     """Number of packets transmitted but not received (transactions failed)."""
68     forwarding_count: int = None
69     """Number of packets successfully forwarded (transactions succeeded)."""
70     # Optional primary quantities.
71     offered_duration: float = None
72     """Estimate of the time [s] the trial was actually transmitting traffic."""
73     duration_with_overheads: float = None
74     """Estimate of the time [s] it took to get the trial result
75     since the measurement started."""
76     intended_count: int = None
77     """Expected number of packets to transmit. If not known,
78     the value of offered_count is used."""
79
80     def __post_init__(self) -> None:
81         """Convert types, compute missing values.
82
83         Current caveats:
84         A failing assumption looks like a conversion error.
85         Negative counts are allowed, which can lead to errors later.
86         """
87         self.intended_duration = float(self.intended_duration)
88         if self.offered_duration is None:
89             self.offered_duration = self.intended_duration
90         else:
91             self.offered_duration = float(self.offered_duration)
92         if self.duration_with_overheads is None:
93             self.duration_with_overheads = self.offered_duration
94         else:
95             self.duration_with_overheads = float(self.duration_with_overheads)
96         self.intended_load = float(self.intended_load)
97         if self.forwarding_count is None:
98             self.forwarding_count = int(self.offered_count) - int(
99                 self.loss_count
100             )
101         else:
102             self.forwarding_count = int(self.forwarding_count)
103         if self.offered_count is None:
104             self.offered_count = self.forwarding_count + int(self.loss_count)
105         else:
106             self.offered_count = int(self.offered_count)
107         if self.loss_count is None:
108             self.loss_count = self.offered_count - self.forwarding_count
109         else:
110             self.loss_count = int(self.loss_count)
111         if self.intended_count is None:
112             self.intended_count = self.offered_count
113         else:
114             self.intended_count = int(self.intended_count)
115             # TODO: Handle (somehow) situations where offered > intended?
116
117     @property
118     def unsent_count(self) -> int:
119         """How many packets were not transmitted (transactions not started).
120
121         :return: Intended count minus offered count.
122         :rtype: int
123         """
124         return self.intended_count - self.offered_count
125
126     @property
127     def loss_ratio(self) -> float:
128         """Bad count divided by overall count, zero if the latter is zero.
129
130         The bad count includes not only loss count, but also unsent count.
131         If unsent count is negative, its absolute value is used.
132         The overall count is intended count or offered count,
133         whichever is bigger.
134
135         Together, the resulting formula tends to increase loss ratio
136         (but not above 100%) in irregular situations,
137         thus guiding search algorithms towards lower loads
138         where there should be less irregularities.
139         The zero default is there to prevent search algorithms from
140         getting stuck on a too low intended load.
141
142         :returns: Bad count divided by overall count.
143         :rtype: float
144         """
145         overall = max(self.offered_count, self.intended_count)
146         bad = abs(self.loss_count) + abs(self.unsent_count)
147         return bad / overall if overall else 0.0
148
149     @property
150     def relative_forwarding_rate(self) -> float:
151         """Forwarding rate in load units as if duration and load was intended.
152
153         The result is based purely on intended load and loss ratio.
154         While the resulting value may be far from what really happened,
155         it has nice behavior with respect to common assumptions
156         of search algorithms.
157
158         :returns: Forwarding rate in load units estimated from loss ratio.
159         :rtype: float
160         """
161         return self.intended_load * (1.0 - self.loss_ratio)