feat(MLRsearch): MLRsearch v7
[csit.git] / resources / libraries / python / MLRsearch / trial_measurement / measurement_result.py
diff --git a/resources/libraries/python/MLRsearch/trial_measurement/measurement_result.py b/resources/libraries/python/MLRsearch/trial_measurement/measurement_result.py
new file mode 100644 (file)
index 0000000..9dc1ccf
--- /dev/null
@@ -0,0 +1,161 @@
+# Copyright (c) 2023 Cisco and/or its affiliates.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at:
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Module defining MeasurementResult class."""
+
+from dataclasses import dataclass
+
+
+@dataclass
+class MeasurementResult:
+    """Structure defining the result of a single trial measurement.
+
+    There are few primary (required) quantities. Various secondary (derived)
+    quantities are calculated and can be queried.
+
+    The constructor allows broader argument types,
+    the post init function converts to the stricter types.
+
+    Integer quantities (counts) are preferred, as float values
+    can suffer from rounding errors, and sometimes they are measured
+    at unknown (possibly very limited) precision and accuracy.
+
+    There are relations between the counts (e.g. offered count
+    should be equal to a sum of forwarding count and loss count).
+    This implementation does not perform consistency checks, but uses them
+    for computing quantities the caller left unspecified.
+
+    In some cases, the units of intended load are different from units
+    of loss count (e.g. load in transactions but loss in packets).
+    Quantities with relative_ prefix can be used to get load candidates
+    from forwarding results.
+
+    Sometimes, the measurement provider is unable to reach the intended load,
+    and it can react by spending longer than intended duration
+    to reach its intended count. To signal irregular situations like this,
+    several optional fields can be given, and various secondary quantities
+    are populated, so the measurement consumer can query the quantity
+    it wants to rely on in these irregular situations.
+
+    The current implementation intentionally limits the secondary quantities
+    to the few that proved useful in practice.
+    """
+
+    # Required primary quantities.
+    intended_duration: float
+    """Intended trial measurement duration [s]."""
+    intended_load: float
+    """Intended load [tps]. If bidirectional (or multi-port) traffic is used,
+    most users will put unidirectional (single-port) value here,
+    as bandwidth and pps limits are usually per-port."""
+    # Two of the next three primary quantities are required.
+    offered_count: int = None
+    """Number of packets actually transmitted (transactions attempted).
+    This should be the aggregate (bidirectional, multi-port) value,
+    so that asymmetric trafic profiles are supported."""
+    loss_count: int = None
+    """Number of packets transmitted but not received (transactions failed)."""
+    forwarding_count: int = None
+    """Number of packets successfully forwarded (transactions succeeded)."""
+    # Optional primary quantities.
+    offered_duration: float = None
+    """Estimate of the time [s] the trial was actually transmitting traffic."""
+    duration_with_overheads: float = None
+    """Estimate of the time [s] it took to get the trial result
+    since the measurement started."""
+    intended_count: int = None
+    """Expected number of packets to transmit. If not known,
+    the value of offered_count is used."""
+
+    def __post_init__(self) -> None:
+        """Convert types, compute missing values.
+
+        Current caveats:
+        A failing assumption looks like a conversion error.
+        Negative counts are allowed, which can lead to errors later.
+        """
+        self.intended_duration = float(self.intended_duration)
+        if self.offered_duration is None:
+            self.offered_duration = self.intended_duration
+        else:
+            self.offered_duration = float(self.offered_duration)
+        if self.duration_with_overheads is None:
+            self.duration_with_overheads = self.offered_duration
+        else:
+            self.duration_with_overheads = float(self.duration_with_overheads)
+        self.intended_load = float(self.intended_load)
+        if self.forwarding_count is None:
+            self.forwarding_count = int(self.offered_count) - int(
+                self.loss_count
+            )
+        else:
+            self.forwarding_count = int(self.forwarding_count)
+        if self.offered_count is None:
+            self.offered_count = self.forwarding_count + int(self.loss_count)
+        else:
+            self.offered_count = int(self.offered_count)
+        if self.loss_count is None:
+            self.loss_count = self.offered_count - self.forwarding_count
+        else:
+            self.loss_count = int(self.loss_count)
+        if self.intended_count is None:
+            self.intended_count = self.offered_count
+        else:
+            self.intended_count = int(self.intended_count)
+            # TODO: Handle (somehow) situations where offered > intended?
+
+    @property
+    def unsent_count(self) -> int:
+        """How many packets were not transmitted (transactions not started).
+
+        :return: Intended count minus offered count.
+        :rtype: int
+        """
+        return self.intended_count - self.offered_count
+
+    @property
+    def loss_ratio(self) -> float:
+        """Bad count divided by overall count, zero if the latter is zero.
+
+        The bad count includes not only loss count, but also unsent count.
+        If unsent count is negative, its absolute value is used.
+        The overall count is intended count or offered count,
+        whichever is bigger.
+
+        Together, the resulting formula tends to increase loss ratio
+        (but not above 100%) in irregular situations,
+        thus guiding search algorithms towards lower loads
+        where there should be less irregularities.
+        The zero default is there to prevent search algorithms from
+        getting stuck on a too low intended load.
+
+        :returns: Bad count divided by overall count.
+        :rtype: float
+        """
+        overall = max(self.offered_count, self.intended_count)
+        bad = abs(self.loss_count) + abs(self.unsent_count)
+        return bad / overall if overall else 0.0
+
+    @property
+    def relative_forwarding_rate(self) -> float:
+        """Forwarding rate in load units as if duration and load was intended.
+
+        The result is based purely on intended load and loss ratio.
+        While the resulting value may be far from what really happened,
+        it has nice behavior with respect to common assumptions
+        of search algorithms.
+
+        :returns: Forwarding rate in load units estimated from loss ratio.
+        :rtype: float
+        """
+        return self.intended_load * (1.0 - self.loss_ratio)