Commit f3f3ec89 authored by Kirill Smelkov's avatar Kirill Smelkov

kpi += Calc

kpi.Calc is calculator to compute KPIs. It can be instantiated on
MeasurementLog and time interval over which to perform computations.
It currently implements calculations for only one "E-RAB Accessibility KPI".

Please see added docstrings and tests for details.

The next patch will also add demo program that uses all kpi.Calc and
other parts of KPI-computation pipeline to build and visualize E-RAB
Accessibility from real data.
parent 71087f67
...@@ -17,7 +17,11 @@ ...@@ -17,7 +17,11 @@
# #
# See COPYING file for full licensing terms. # See COPYING file for full licensing terms.
# See https://www.nexedi.com/licensing for rationale and options. # See https://www.nexedi.com/licensing for rationale and options.
"""Package kpi will provide functionality to compute Key Performance Indicators of LTE services. """Package kpi provides functionality to compute Key Performance Indicators of LTE services.
- Calc is KPI calculator. It can be instantiated on MeasurementLog and time
interval over which to perform computations. Use Calc methods such as
.erab_accessibility() to compute KPIs.
- MeasurementLog maintains journal with result of measurements. Use .append() - MeasurementLog maintains journal with result of measurements. Use .append()
to populate it with data. to populate it with data.
...@@ -25,18 +29,52 @@ ...@@ -25,18 +29,52 @@
- Measurement represents measurement results. Its documentation establishes - Measurement represents measurement results. Its documentation establishes
semantic for measurement results to be followed by drivers. semantic for measurement results to be followed by drivers.
To actually compute a KPI for particular LTE service, a measurements driver
should exist for that LTE service(*). KPI computation pipeline is then as follows:
─────────────
│ Measurement │ Measurements ──────────────── ──────
│ │ ─────────────→ │ MeasurementLog │ ──→ │ Calc │ ──→ KPI
│ driver │ ──────────────── ──────
─────────────
See following 3GPP standards for KPI-related topics: See following 3GPP standards for KPI-related topics:
- TS 32.401 - TS 32.401
- TS 32.450 - TS 32.450
- TS 32.425 - TS 32.425
(*) for example package amari.kpi provides such measurements driver for Amarisoft LTE stack.
""" """
import numpy as np import numpy as np
from golang import func from golang import func
# Calc provides way to compute KPIs over given measurement data and time interval.
#
# It is constructed from MeasurementLog and [τ_lo, τ_hi) and further provides
# following methods for computing 3GPP KPIs:
#
# .erab_accessibility() - TS 32.450 6.1.1 "E-RAB Accessibility"
# TODO other KPIs
#
# Upon construction specified time interval is potentially widened to cover
# corresponding data in full granularity periods:
#
# τ'lo τ'hi
# ──────|─────|────[────|────)──────|──────|────────>
# ←─ τ_lo τ_hi ──→ time
#
#
# See also: MeasurementLog, Measurement.
class Calc:
# ._data []Measurement - fully inside [.τ_lo, .τ_hi)
# [.τ_lo, .τ_hi) time interval to compute over. Potentially wider than originally requested.
pass
# MeasurementLog represent journal of performed Measurements. # MeasurementLog represent journal of performed Measurements.
# #
# It semantically consists of # It semantically consists of
...@@ -162,6 +200,18 @@ class Measurement(np.void): ...@@ -162,6 +200,18 @@ class Measurement(np.void):
]) ])
# Interval is NumPy structured scalar that represents [lo,hi) interval.
#
# It is used by Calc to represent confidence interval for computed KPIs.
# NOTE Interval is likely to be transient solution and in the future its usage
# will be probably changed to something like uncertainties.ufloat .
class Interval(np.void):
_dtype = np.dtype([
('lo', np.float64),
('hi', np.float64),
])
# ---------------------------------------- # ----------------------------------------
# Measurement is the central part around which everything is organized. # Measurement is the central part around which everything is organized.
# Let's have it go first. # Let's have it go first.
...@@ -328,6 +378,250 @@ def forget_past(mlog, Tcut): ...@@ -328,6 +378,250 @@ def forget_past(mlog, Tcut):
# ---------------------------------------- # ----------------------------------------
# Calc() is initialized from slice of data in the measurement log that is
# covered/overlapped with [τ_lo, τ_hi) time interval.
#
# The time interval, that will actually be used for computations, is potentially wider.
# See Calc class documentation for details.
@func(Calc)
def __init__(calc, mlog: MeasurementLog, τ_lo, τ_hi):
assert τ_lo <= τ_hi
data = mlog.data()
l = len(data)
# find min i: τ_lo < [i].(Tstart+δT) ; i=l if not found
# TODO binary search
i = 0
while i < l:
m = data[i]
m_τhi = m['X.Tstart'] + m['X.δT']
if τ_lo < m_τhi:
break
i += 1
# find min j: τ_hi ≤ [j].Tstart ; j=l if not found
j = i
while j < l:
m = data[j]
m_τlo = m['X.Tstart']
if τ_hi <= m_τlo:
break
j += 1
data = data[i:j]
if len(data) > 0:
m_lo = data[0]
m_hi = data[-1]
τ_lo = min(τ_lo, m_lo['X.Tstart'])
τ_hi = max(τ_hi, m_hi['X.Tstart']+m_hi['X.δT'])
calc._data = data
calc.τ_lo = τ_lo
calc.τ_hi = τ_hi
# erab_accessibility computes "E-RAB Accessibility" KPI.
#
# It returns the following items:
#
# - InitialEPSBEstabSR probability of successful initial E-RAB establishment (%)
# - AddedEPSBEstabSR probability of successful additional E-RAB establishment (%)
#
# The items are returned as Intervals with information about confidence for
# computed values.
#
# 3GPP reference: TS 32.450 6.1.1 "E-RAB Accessibility".
@func(Calc)
def erab_accessibility(calc): # -> InitialEPSBEstabSR, AddedEPSBEstabSR
SR = calc._success_rate
x = SR("Σcause RRC.ConnEstabSucc.CAUSE",
"Σcause RRC.ConnEstabAtt.CAUSE")
y = SR("S1SIG.ConnEstabSucc",
"S1SIG.ConnEstabAtt")
z = SR("Σqci ERAB.EstabInitSuccNbr.QCI",
"Σqci ERAB.EstabInitAttNbr.QCI")
InititialEPSBEstabSR = Interval(x['lo'] * y['lo'] * z['lo'], # x·y·z
x['hi'] * y['hi'] * z['hi'])
AddedEPSBEstabSR = SR("Σqci ERAB.EstabAddSuccNbr.QCI",
"Σqci ERAB.EstabAddAttNbr.QCI")
return _i2pc(InititialEPSBEstabSR), \
_i2pc(AddedEPSBEstabSR) # as %
# _success_rate computes success rate for fini/init events.
#
# i.e. ratio N(fini)/N(init).
#
# 3GPP defines success rate as N(successful-events) / N(total_events) ratio,
# for example N(connection_established) / N(connection_attempt). We take this
# definition as is for granularity periods with data, and extend it to also
# account for time intervals covered by Calc where measurements results are not
# available.
#
# To do so we extrapolate N(init) to be also contributed by "no data" periods
# proportionally to "no data" time coverage, and then we note that in those
# times, since no measurements have been made, the number of success events is
# unknown and can lie anywhere in between 0 and the number of added init events.
#
# This gives the following for resulting success rate confidence interval:
#
# time covered by periods with data: Σt
# time covered by periods with no data: t⁺ t⁺
# extrapolation for incoming initiation events: init⁺ = ──·Σ(init)
# Σt
# fini events for "no data" time is full uncertainty: fini⁺ ∈ [0,init⁺]
#
# => success rate over whole time is uncertain in between
#
# Σ(fini) Σ(fini) + init⁺
# ────────────── ≤ SR ≤ ──────────────
# Σ(init) + init⁺ Σ(init) + init⁺
#
# that confidence interval is returned as the result.
#
# fini/init events can be prefixed with "Σqci " or "Σcause ". If such prefix is
# present, then fini/init value is obtained via call to Σqci or Σcause correspondingly.
@func(Calc)
def _success_rate(calc, fini, init): # -> Interval in [0,1]
def vget(m, name):
if name.startswith("Σqci "):
return Σqci (m, name[len("Σqci "):])
if name.startswith("Σcause "):
return Σcause(m, name[len("Σcause "):])
return m[name]
t_ = 0.
Σt = 0.
Σinit = 0
Σfini = 0
Σufini = 0 # Σinit where fini=ø but init is not ø
for m in calc._miter():
τ = m['X.δT']
vinit = vget(m, init)
vfini = vget(m, fini)
if isNA(vinit):
t_ += τ
# ignore fini, even if it is not ø.
# TODO more correct approach: init⁺ for this period ∈ [fini,∞] and
# once we extrapolate init⁺ we should check if it lies in that
# interval and adjust if not. Then fini could be used as is.
else:
Σt += τ
Σinit += vinit
if isNA(vfini):
Σufini += vinit
else:
Σfini += vfini
if Σinit == 0 or Σt == 0:
return Interval(0,1) # full uncertainty
init_ = t_ * Σinit / Σt
a = Σfini / (Σinit + init_)
b = (Σfini + init_ + Σufini) / (Σinit + init_)
return Interval(a,b)
# _miter iterates through [.τ_lo, .τ_hi) yielding Measurements.
#
# The measurements are yielded with consecutive timestamps. There is no gaps
# as NA Measurements are yielded for time holes in original MeasurementLog data.
@func(Calc)
def _miter(calc): # -> iter(Measurement)
τ = calc.τ_lo
l = len(calc._data)
i = 0 # current Measurement from data
while i < l:
m = calc._data[i]
m_τlo = m['X.Tstart']
m_τhi = m_τlo + m['X.δT']
assert m_τlo < m_τhi
if τ < m_τlo:
# <- M(ø)[τ, m_τlo)
h = Measurement()
h['X.Tstart'] = τ
h['X.δT'] = m_τlo - τ
yield h
# <- M from mlog
yield m
τ = m_τhi
i += 1
assert τ <= calc.τ_hi
if τ < calc.τ_hi:
# <- trailing M(ø)[τ, τ_hi)
h = Measurement()
h['X.Tstart'] = τ
h['X.δT'] = calc.τ_hi - τ
yield h
# Interval(lo,hi) creates new interval with specified boundaries.
@func(Interval)
def __new__(cls, lo, hi):
i = _newscalar(cls, cls._dtype)
i['lo'] = lo
i['hi'] = hi
return i
# Σqci performs summation over all qci for m[name_qci].
#
# usage example:
#
# Σqci(m, 'ERAB.EstabInitSuccNbr.QCI')
#
# name_qci must have '.QCI' suffix.
def Σqci(m: Measurement, name_qci: str):
return _Σx(m, name_qci, _all_qci)
# Σcause, performs summation over all causes for m[name_cause].
#
# usage example:
#
# Σcause(m, 'RRC.ConnEstabSucc.CAUSE')
#
# name_cause must have '.CAUSE' suffix.
def Σcause(m: Measurement, name_cause: str):
return _Σx(m, name_cause, _all_cause)
# _Σx serves Σqci and Σcause.
def _Σx(m: Measurement, name_x: str, _all_x: func):
name_sum, name_xv = _all_x(name_x)
s = m[name_sum]
if not isNA(s):
return s
s = s.dtype.type(0)
ok = True
for _ in name_xv:
v = m[_]
# we don't know the answer even if single value is NA
# (if data source does not support particular qci/cause, it should set it to 0)
if isNA(v):
ok = False
else:
s += v
if not ok:
return NA(s.dtype)
else:
return s
# _i2pc maps Interval in [0,1] to one in [0,100] by multiplying lo/hi by 1e2.
def _i2pc(x: Interval): # -> Interval
return Interval(x['lo']*100, x['hi']*100)
# _newscalar creates new NumPy scalar instance with specified type and dtype. # _newscalar creates new NumPy scalar instance with specified type and dtype.
def _newscalar(typ, dtype): def _newscalar(typ, dtype):
_ = np.zeros(shape=(), dtype=(typ, dtype)) _ = np.zeros(shape=(), dtype=(typ, dtype))
......
...@@ -18,7 +18,7 @@ ...@@ -18,7 +18,7 @@
# See COPYING file for full licensing terms. # See COPYING file for full licensing terms.
# See https://www.nexedi.com/licensing for rationale and options. # See https://www.nexedi.com/licensing for rationale and options.
from xlte.kpi import MeasurementLog, Measurement, NA, isNA from xlte.kpi import Calc, MeasurementLog, Measurement, Interval, NA, isNA
import numpy as np import numpy as np
from pytest import raises from pytest import raises
...@@ -145,6 +145,281 @@ def test_MeasurementLog(): ...@@ -145,6 +145,281 @@ def test_MeasurementLog():
assert _.shape == (0,) assert _.shape == (0,)
# verify (τ_lo, τ_hi) widening and overlapping with Measurements on Calc initialization.
def test_Calc_init():
mlog = MeasurementLog()
# _ asserts that Calc(mlog, τ_lo,τ_hi) has .τ_lo/.τ_hi as specified by
# τ_wlo/τ_whi, and ._data as specified by mokv.
def _(τ_lo, τ_hi, τ_wlo, τ_whi, *mokv):
c = Calc(mlog, τ_lo,τ_hi)
assert (c.τ_lo, c.τ_hi) == (τ_wlo, τ_whi)
mv = list(c._data[i] for i in range(len(c._data)))
assert mv == list(mokv)
# mlog(ø)
_( 0, 0, 0,0)
_( 0,99, 0,99)
_(10,20, 10,20)
# m1[10,20)
m1 = Measurement()
m1['X.Tstart'] = 10
m1['X.δT'] = 10
mlog.append(m1)
_( 0, 0, 0, 0)
_( 0,99, 0,99, m1)
_(10,20, 10,20, m1)
_(12,18, 10,20, m1)
_( 5, 7, 5, 7)
_( 5,15, 5,20, m1)
_(15,25, 10,25, m1)
_(25,30, 25,30)
# m1[10,20) m2[30,40)
m2 = Measurement()
m2['X.Tstart'] = 30
m2['X.δT'] = 10
mlog.append(m2)
_( 0, 0, 0, 0)
_( 0,99, 0,99, m1, m2)
_(10,20, 10,20, m1)
_(12,18, 10,20, m1)
_( 5, 7, 5, 7)
_( 5,15, 5,20, m1)
_(15,25, 10,25, m1)
_(25,30, 25,30)
_(25,35, 25,40, m2)
_(35,45, 30,45, m2)
_(45,47, 45,47)
_(32,38, 30,40, m2)
_(30,40, 30,40, m2)
_(99,99, 99,99)
# verify Calc internal iteration over measurements and holes.
def test_Calc_miter():
mlog = MeasurementLog()
# _ asserts that Calc(mlog, τ_lo,τ_hi)._miter yields Measurement as specified by mokv.
def _(τ_lo, τ_hi, *mokv):
c = Calc(mlog, τ_lo,τ_hi)
mv = list(c._miter())
assert mv == list(mokv)
# na returns Measurement with specified τ_lo/τ_hi and NA for all other data.
def na(τ_lo, τ_hi):
assert τ_lo <= τ_hi
m = Measurement()
m['X.Tstart'] = τ_lo
m['X.δT'] = τ_hi - τ_lo
return m
# mlog(ø)
_( 0, 0)
_( 0,99, na(0,99))
_(10,20, na(10,20))
# m1[10,20)
m1 = Measurement()
m1['X.Tstart'] = 10
m1['X.δT'] = 10
mlog.append(m1)
_( 0, 0)
_( 0,99, na(0,10), m1, na(20,99))
_(10,20, m1)
_( 7,20, na(7,10), m1)
_(10,23, m1, na(20,23))
# m1[10,20) m2[30,40)
m2 = Measurement()
m2['X.Tstart'] = 30
m2['X.δT'] = 10
mlog.append(m2)
_( 0, 0)
_( 0,99, na(0,10), m1, na(20,30), m2, na(40,99))
_(10,20, m1)
_(10,30, m1, na(20,30))
_(10,40, m1, na(20,30), m2)
# verify Calc internal function that computes success rate of fini/init events.
def test_Calc_success_rate():
mlog = MeasurementLog()
init = "S1SIG.ConnEstabAtt"
fini = "S1SIG.ConnEstabSucc"
# M returns Measurement with specified time coverage and init/fini values.
def M(τ_lo,τ_hi, vinit=None, vfini=None):
m = Measurement()
m['X.Tstart'] = τ_lo
m['X.δT'] = τ_hi - τ_lo
if vinit is not None:
m[init] = vinit
if vfini is not None:
m[fini] = vfini
return m
# Mlog reinitializes mlog according to specified Measurements in mv.
def Mlog(*mv):
nonlocal mlog
mlog = MeasurementLog()
for m in mv:
mlog.append(m)
# _ asserts that Calc(mlog, τ_lo,τ_hi)._success_rate(fini, init) returns Interval(sok_lo, sok_hi).
def _(τ_lo, τ_hi, sok_lo, sok_hi):
sok = Interval(sok_lo, sok_hi)
c = Calc(mlog, τ_lo, τ_hi)
s = c._success_rate(fini, init)
assert type(s) is Interval
eps = np.finfo(s['lo'].dtype).eps
assert abs(s['lo']-sok['lo']) < eps
assert abs(s['hi']-sok['hi']) < eps
# ø -> full uncertainty
Mlog()
_( 0, 0, 0,1)
_( 0,99, 0,1)
_(10,20, 0,1)
# m[10,20, {ø,0}/{ø,0}) -> full uncertainty
for i in (None,0):
for f in (None,0):
Mlog(M(10,20, i,f))
_( 0, 0, 0,1)
_( 0,99, 0,1)
_(10,20, 0,1)
_( 7,20, 0,1)
_(10,25, 0,1)
# m[10,20, 8,4) -> 1/2 if counted in [10,20)
#
# i₁=8
# f₁=4
# ────|──────|─────────────|──────────
# 10 t₁ 20 ←── t₂ ──→ τ_hi
#
# t with data: t₁
# t with no data: t₂
# t total: T = t₁+t₂
# extrapolation for incoming t₂
# events for "no data" period: i₂ = i₁·──
# t₁
# termination events for "no data"
# period is full uncertainty f₂ ∈ [0,i₂]
#
# => success rate over whole time is uncertain in between
#
# f₁ f₁+i₂
# ───── ≤ SR ≤ ─────
# i₁+i₂ i₁+i₂
#
Mlog(M(10,20, 8,4))
_( 0, 0, 0, 1) # no overlap - full uncertainty
_(10,20, 0.5, 0.5) # t₂=0 - no uncertainty
_( 7,20, 0.3846153846153846, 0.6153846153846154) # t₂=3
_(10,25, 0.3333333333333333, 0.6666666666666666) # t₂=5
_( 0,99, 0.050505050505050504, 0.9494949494949495) # t₂=10+79
# m[10,20, 8,4) m[30,40, 50,50]
#
# similar to the above case but with t₁ and t₂ coming with data, while t₃
# represents whole "no data" time:
#
# i₁=8 i₂=50
# f₁=4 f₂=50
# ────|──────|──────|───────|──────────────────|──────────
# 10 t₁ 20 ↑ 30 t₂ 40 ↑ τ_hi
# │ │
# │ │
# `────────────────── t₃
#
# t with data: t₁+t₂
# t with no data: t₃
# t total: T = t₁+t₂+t₃
# extrapolation for incoming t₃
# events for "no data" period: i₃ = (i₁+i₂)·────
# t₁+t₂
# termination events for "no data"
# period is full uncertainty f₃ ∈ [0,i₃]
#
# => success rate over whole time is uncertain in between
#
# f₁+f₂ f₁+f₂+i₃
# ──────── ≤ SR ≤ ───────
# i₁+i₂+i₃ i₁+i₂+i₃
#
Mlog(M(10,20, 8,4), M(30,40, 50,50))
_( 0, 0, 0, 1) # no overlap - full uncertainty
_(10,20, 0.5, 0.5) # exact 1/2 in [10,20)
_(30,40, 1, 1) # exact 1 in [30,40)
_( 7,20, 0.3846153846153846, 0.6153846153846154) # overlaps only with t₁ -> as ^^^
_(10,25, 0.3333333333333333, 0.6666666666666666) # overlaps only with t₁ -> as ^^^
_(10,40, 0.6206896551724138, 0.9540229885057471) # t₃=10
_( 7,40, 0.5642633228840125, 0.9582027168234065) # t₃=13
_( 7,45, 0.4900181488203267, 0.9637023593466425) # t₃=18
_( 0,99, 0.18808777429467083, 0.9860675722744688) # t₃=79
# Σqci
init = "Σqci ERAB.EstabInitAttNbr.QCI"
fini = "Σqci ERAB.EstabInitSuccNbr.QCI"
m = M(10,20)
m['ERAB.EstabInitAttNbr.sum'] = 10
m['ERAB.EstabInitSuccNbr.sum'] = 2
Mlog(m)
_(10,20, 1/5, 1/5)
# Σcause
init = "Σcause RRC.ConnEstabAtt.CAUSE"
fini = "Σcause RRC.ConnEstabSucc.CAUSE"
m = M(10,20)
m['RRC.ConnEstabSucc.sum'] = 5
m['RRC.ConnEstabAtt.sum'] = 10
Mlog(m)
_(10,20, 1/2, 1/2)
# verify Calc.erab_accessibility .
def test_Calc_erab_accessibility():
# most of the job is done by _success_rate.
# here we verify final wrapping, that erab_accessibility does, only lightly.
m = Measurement()
m['X.Tstart'] = 10
m['X.δT'] = 10
m['RRC.ConnEstabSucc.sum'] = 2
m['RRC.ConnEstabAtt.sum'] = 7
m['S1SIG.ConnEstabSucc'] = 3
m['S1SIG.ConnEstabAtt'] = 8
m['ERAB.EstabInitSuccNbr.sum'] = 4
m['ERAB.EstabInitAttNbr.sum'] = 9
m['ERAB.EstabAddSuccNbr.sum'] = 5
m['ERAB.EstabAddAttNbr.sum'] = 10
mlog = MeasurementLog()
mlog.append(m)
calc = Calc(mlog, 10,20)
# _ asserts that provided interval is precise and equals vok.
def _(i: Interval, vok):
assert i['lo'] == i['hi']
assert i['lo'] == vok
InititialEPSBEstabSR, AddedEPSBEstabSR = calc.erab_accessibility()
_(AddedEPSBEstabSR, 50)
_(InititialEPSBEstabSR, 100 * 2*3*4 / (7*8*9))
def test_NA(): def test_NA():
def _(typ): def _(typ):
return NA(typ(0).dtype) return NA(typ(0).dtype)
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment