Commit 7cd9cb91 authored by Kirill Smelkov's avatar Kirill Smelkov

kpi: Establish data model for DRB.IPLatDl.QCI

Since the beginning - since dc1d5481 (kpi: Start of the package)
DRB.IPLatDl.QCI was introduced only in commented form with the following
remark:

        # XXX mean is not good for our model
        # TODO mean -> total + npkt?
        #('DRB.IPLatDl.QCI',                Ttime),     # s         4.4.5.1  32.450:6.3.2   NOTE not ms

The problem here is that if we introduce DRB.IPLatDl.QCI as just Ttime for
average latency, and we have two measurements m1 and m2 with such
DRB.IPLatDl, there is no way to know what DRB.IPLatDl should be
for aggregated measurement - in the aggregated measurement the latency
should be the mean time - averaged for combined periods of m1+m2, over
samples of all transmision bursts. And knowing something already averaged in
period1 and period2 we can compute the average for aggregated measurement
only if we know both initial averages _and_ the number of samples in each
period. That's what the "TODO mean -> total + npkt?" comment was about.

Besides DRB.IPLatDl there are many other values that 3GPP say to be
mean. For example UE.Active and other values. So there is a need to
uniformly represent such averages somehow and that there is a way to
also aggregate the averages for combined measurements.

-> Introduce Stat type, that represents results of statistical profiling
   and use it for DRB.IPLatDl.QCI; Teach Calc.aggregate to handle
   aggregation of such statistical profiles via

            a₁⋅n₁ + a₂·n₂
        A = ─────────────
               n₁ + n₂

   formula.
parent 205616f2
...@@ -121,6 +121,23 @@ class MeasurementLog: ...@@ -121,6 +121,23 @@ class MeasurementLog:
pass pass
# Stat[dtype] represents result of statistical profiling with arbitrary sampling
# for a value with specified dtype.
#
# It is organized as NumPy structured scalar with avg, min, max and n fields.
#
# It is used inside Measurement for e.g. DRB.IPLatDl.QCI .
class Stat(np.void):
# _dtype_for returns dtype that Stat[dtype] will use.
@classmethod
def _dtype_for(cls, dtype):
return np.dtype((cls, [
('avg', np.float64), # NOTE even int becomes float on averaging
('min', dtype),
('max', dtype),
('n', np.int64)]))
# Measurement represents set of measured values and events observed and counted # Measurement represents set of measured values and events observed and counted
# during one particular period of time. # during one particular period of time.
# #
...@@ -157,6 +174,7 @@ class MeasurementLog: ...@@ -157,6 +174,7 @@ class MeasurementLog:
class Measurement(np.void): class Measurement(np.void):
Tcc = np.int32 # cumulative counter Tcc = np.int32 # cumulative counter
Ttime = np.float64 # time is represented in seconds since epoch Ttime = np.float64 # time is represented in seconds since epoch
S = Stat ._dtype_for # statistical profile with arbitrary sampling
# _dtype defines measured values and events. # _dtype defines measured values and events.
_dtype = np.dtype([ _dtype = np.dtype([
...@@ -166,7 +184,7 @@ class Measurement(np.void): ...@@ -166,7 +184,7 @@ class Measurement(np.void):
# below come values/events as specified by TS 32.425 and TS 32.450 # below come values/events as specified by TS 32.425 and TS 32.450
# NOTE all .QCI and .CAUSE are expanded from outside. # NOTE all .QCI and .CAUSE are expanded from outside.
# #
# NAME TYPE UNIT TS 32.425 reference + ... # NAME TYPE/DTYPE UNIT TS 32.425 reference + ...
('RRC.ConnEstabAtt.CAUSE', Tcc), # 1 4.1.1.1 ('RRC.ConnEstabAtt.CAUSE', Tcc), # 1 4.1.1.1
('RRC.ConnEstabSucc.CAUSE', Tcc), # 1 4.1.1.2 ('RRC.ConnEstabSucc.CAUSE', Tcc), # 1 4.1.1.2
...@@ -181,9 +199,8 @@ class Measurement(np.void): ...@@ -181,9 +199,8 @@ class Measurement(np.void):
('DRB.PdcpSduBitrateUl.QCI', np.float64),# bit/s 4.4.1.1 NOTE not kbit/s ('DRB.PdcpSduBitrateUl.QCI', np.float64),# bit/s 4.4.1.1 NOTE not kbit/s
('DRB.PdcpSduBitrateDl.QCI', np.float64),# bit/s 4.4.1.2 NOTE not kbit/s ('DRB.PdcpSduBitrateDl.QCI', np.float64),# bit/s 4.4.1.2 NOTE not kbit/s
# XXX mean is not good for our model
# TODO mean -> total + npkt? ('DRB.IPLatDl.QCI', S(Ttime)), # s 4.4.5.1 32.450:6.3.2 NOTE not ms
#('DRB.IPLatDl.QCI', Ttime), # s 4.4.5.1 32.450:6.3.2 NOTE not ms
# DRB.IPThpX.QCI = DRB.IPVolX.QCI / DRB.IPTimeX.QCI 4.4.6.1-2 32.450:6.3.1 # DRB.IPThpX.QCI = DRB.IPVolX.QCI / DRB.IPTimeX.QCI 4.4.6.1-2 32.450:6.3.1
('DRB.IPVolDl.QCI', np.int64), # bit 4.4.6.3 32.450:6.3.1 NOTE not kbit ('DRB.IPVolDl.QCI', np.int64), # bit 4.4.6.3 32.450:6.3.1 NOTE not kbit
...@@ -208,6 +225,8 @@ class Measurement(np.void): ...@@ -208,6 +225,8 @@ class Measurement(np.void):
('PEE.Energy', np.float64),# J 4.12.2 NOTE not kWh ('PEE.Energy', np.float64),# J 4.12.2 NOTE not kWh
]) ])
del S
# Interval is NumPy structured scalar that represents [lo,hi) interval. # Interval is NumPy structured scalar that represents [lo,hi) interval.
# #
...@@ -275,6 +294,16 @@ def __new__(cls): ...@@ -275,6 +294,16 @@ def __new__(cls):
Σ[field]['τ_na'] = 0 Σ[field]['τ_na'] = 0
return Σ return Σ
# Stat() creates new Stat instance with specified values and dtype.
@func(Stat)
def __new__(cls, min, avg, max, n, dtype=np.float64):
s = _newscalar(cls, cls._dtype_for(dtype))
s['min'] = min
s['avg'] = avg
s['max'] = max
s['n'] = n
return s
# _all_qci expands <name>.QCI into <name>.sum and [] of <name>.<qci> for all possible qci values. # _all_qci expands <name>.QCI into <name>.sum and [] of <name>.<qci> for all possible qci values.
# TODO remove and use direct array access (after causes are expanded into array too) # TODO remove and use direct array access (after causes are expanded into array too)
...@@ -368,6 +397,21 @@ def __str__(m): ...@@ -368,6 +397,21 @@ def __str__(m):
vv.append(_vstr(m[field])) vv.append(_vstr(m[field]))
return "(%s)" % ', '.join(vv) return "(%s)" % ', '.join(vv)
# __repr__ returns Stat(min, avg, max, n, dtype=...)
# NA values are represented as "ø".
@func(Stat)
def __repr__(s):
return "Stat(%s, %s, %s, %s, dtype=%s)" % (_vstr(s['min']), _vstr(s['avg']),
_vstr(s['max']), _vstr(s['n']), s['min'].dtype)
# __str__ returns "<min avg max>·n"
# NA values are represented as "ø".
@func(Stat)
def __str__(s):
return "<%s %s %s>·%s" % (_vstr(s['min']), _vstr(s['avg']), _vstr(s['max']), _vstr(s['n']))
# _vstr returns string representation of scalar or subarray v. # _vstr returns string representation of scalar or subarray v.
def _vstr(v): # -> str def _vstr(v): # -> str
if v.shape == (): # scalar if v.shape == (): # scalar
...@@ -379,9 +423,17 @@ def _vstr(v): # -> str ...@@ -379,9 +423,17 @@ def _vstr(v): # -> str
va = [] # subarray with some non-ø data va = [] # subarray with some non-ø data
for k in range(v.shape[0]): for k in range(v.shape[0]):
if v[k] == 0: vk = v[k]
continue if isinstance(vk, np.void):
va.append('%d:%s' % (k, 'ø' if isNA(v[k]) else str(v[k]))) for name in vk.dtype.names:
if vk[name] != 0:
break
else:
continue
else:
if vk == 0:
continue
va.append('%d:%s' % (k, 'ø' if isNA(vk) else str(vk)))
return "{%s}" % ' '.join(va) return "{%s}" % ' '.join(va)
...@@ -424,8 +476,14 @@ def _check_valid(m): ...@@ -424,8 +476,14 @@ def _check_valid(m):
continue continue
# * ≥ 0 # * ≥ 0
if v < 0: if not isinstance(v, np.void):
bad(".%s < 0 (%s)" % (field, v)) if v < 0:
bad(".%s < 0 (%s)" % (field, v))
else:
for vfield in v.dtype.names:
vf = v[vfield]
if not isNA(vf) and vf < 0:
bad(".%s.%s < 0 (%s)" % (field, vfield, vf))
# fini ≤ init # fini ≤ init
if "Succ" in field: if "Succ" in field:
...@@ -705,6 +763,25 @@ def aggregate(calc): # -> ΣMeasurement ...@@ -705,6 +763,25 @@ def aggregate(calc): # -> ΣMeasurement
Σ['X.Tstart'] = calc.τ_lo Σ['X.Tstart'] = calc.τ_lo
Σ['X.δT'] = calc.τ_hi - calc.τ_lo Σ['X.δT'] = calc.τ_hi - calc.τ_lo
def xmin(a, b):
if isNA(a): return b
if isNA(b): return a
return min(a, b)
def xmax(a, b):
if isNA(a): return b
if isNA(b): return a
return max(a, b)
def xavg(a, na, b, nb): # -> <ab>, na+nb
if isNA(a) or isNA(na):
return b, nb
if isNA(b) or isNA(nb):
return a, na
nab = na+nb
ab = (a*na + b*nb)/nab
return ab, nab
for m in calc._miter(): for m in calc._miter():
for field in m.dtype.names: for field in m.dtype.names:
if field.startswith('X.'): # X.Tstart, X.δT if field.startswith('X.'): # X.Tstart, X.δT
...@@ -714,12 +791,29 @@ def aggregate(calc): # -> ΣMeasurement ...@@ -714,12 +791,29 @@ def aggregate(calc): # -> ΣMeasurement
if v.shape != (): # skip subarrays - rely on aliases if v.shape != (): # skip subarrays - rely on aliases
continue continue
Σf = Σ[field] # view to Σ[field]
Σv = Σf['value'] # view to Σ[field]['value']
if isNA(v): if isNA(v):
Σ[field]['τ_na'] += m['X.δT'] Σf['τ_na'] += m['X.δT']
continue
if isNA(Σv):
Σf['value'] = v
continue
if isinstance(v, np.number):
Σf['value'] += v
elif isinstance(v, Stat):
Σv['min'] = xmin(Σv['min'], v['min'])
Σv['max'] = xmax(Σv['max'], v['max'])
# TODO better sum everything and then divide as a whole to avoid loss of precision
Σv['avg'], Σv['n'] = xavg(Σv['avg'], Σv['n'],
v['avg'], v['n'])
else: else:
if isNA(Σ[field]['value']): raise AssertionError("Calc.aggregate: unexpected type %r" % type(v))
Σ[field]['value'] = 0
Σ[field]['value'] += v
return Σ return Σ
...@@ -840,15 +934,20 @@ def NA(dtype): ...@@ -840,15 +934,20 @@ def NA(dtype):
typ = dtype.type typ = dtype.type
# float # float
if issubclass(typ, np.floating): if issubclass(typ, np.floating):
na = np.nan na = typ(np.nan) # return the same type as dtype has, e.g. np.int32, not int
# int: NA is min value # int: NA is min value
elif issubclass(typ, np.signedinteger): elif issubclass(typ, np.signedinteger):
na = np.iinfo(typ).min na = typ(np.iinfo(typ).min)
# structure: NA is combination of NAs for fields
elif issubclass(typ, np.void):
na = _newscalar(typ, dtype)
for field in dtype.names:
na[field] = NA(dtype.fields[field][0])
else: else:
raise AssertionError("NA not defined for dtype %s" % (dtype,)) raise AssertionError("NA not defined for dtype %s" % (dtype,))
return typ(na) # return the same type as dtype has, e.g. np.int32, not int assert type(na) is typ
return na
# isNA returns whether value represent NA. # isNA returns whether value represent NA.
...@@ -857,6 +956,26 @@ def NA(dtype): ...@@ -857,6 +956,26 @@ def NA(dtype):
# returns array(True/False) if value is array. # returns array(True/False) if value is array.
def isNA(value): def isNA(value):
na = NA(value.dtype) na = NA(value.dtype)
if np.isnan(na):
return np.isnan(value) # `nan == nan` gives False # `nan == nan` gives False
# work it around by checking for nan explicitly
if isinstance(na, np.void): # items are structured scalars
vna = None
for field in value.dtype.names:
nf = na[field]
vf = value[field]
if np.isnan(nf):
x = np.isnan(vf)
else:
x = (vf == nf)
if vna is None:
vna = x
else:
vna &= x
return vna
else:
if np.isnan(na):
return np.isnan(value)
return value == na return value == na
...@@ -21,10 +21,12 @@ ...@@ -21,10 +21,12 @@
from __future__ import print_function, division, absolute_import from __future__ import print_function, division, absolute_import
from xlte.kpi import Calc, MeasurementLog, Measurement, ΣMeasurement, Interval, \ from xlte.kpi import Calc, MeasurementLog, Measurement, ΣMeasurement, Interval, \
NA, isNA, Σqci, Σcause, nqci Stat, NA, isNA, Σqci, Σcause, nqci
import numpy as np import numpy as np
from pytest import raises from pytest import raises
ms = 1e-3
def test_Measurement(): def test_Measurement():
m = Measurement() m = Measurement()
...@@ -44,6 +46,8 @@ def test_Measurement(): ...@@ -44,6 +46,8 @@ def test_Measurement():
_('DRB.IPVolDl.sum') # int64 _('DRB.IPVolDl.sum') # int64
_('DRB.IPTimeDl.7') # .QCI alias _('DRB.IPTimeDl.7') # .QCI alias
_('DRB.IPTimeDl.QCI') # .QCI array _('DRB.IPTimeDl.QCI') # .QCI array
_('DRB.IPLatDl.7') # .QCI alias to Stat
_('DRB.IPLatDl.QCI') # .QCI array of Stat
# everything automatically # everything automatically
for name in m.dtype.names: for name in m.dtype.names:
_(name) _(name)
...@@ -66,9 +70,21 @@ def test_Measurement(): ...@@ -66,9 +70,21 @@ def test_Measurement():
continue continue
assert m['DRB.IPVolDl.%d' % k] == 0 assert m['DRB.IPVolDl.%d' % k] == 0
assert m['DRB.IPVolDl.QCI'][k] == 0 assert m['DRB.IPVolDl.QCI'][k] == 0
m['DRB.IPLatDl.QCI'][:]['avg'] = 0
m['DRB.IPLatDl.QCI'][:]['min'] = 0
m['DRB.IPLatDl.QCI'][:]['max'] = 0
m['DRB.IPLatDl.QCI'][:]['n'] = 0
m['DRB.IPLatDl.QCI'][3]['avg'] = 33
m['DRB.IPLatDl.QCI'][3]['n'] = 123
m['DRB.IPLatDl.4']['avg'] = 44
m['DRB.IPLatDl.4']['n'] = 432
m['DRB.IPLatDl.8']['avg'] = NA(m['DRB.IPLatDl.8']['avg'].dtype)
m['DRB.IPLatDl.8']['n'] = NA(m['DRB.IPLatDl.8']['n'] .dtype)
# str/repr # str/repr
assert repr(m) == "Measurement(RRC.ConnEstabAtt.sum=17, DRB.IPVolDl.QCI={5:55 7:ø 9:99}, S1SIG.ConnEstabAtt=123)" assert repr(m) == "Measurement(RRC.ConnEstabAtt.sum=17, DRB.IPLatDl.QCI={3:<0.0 33.0 0.0>·123 4:<0.0 44.0 0.0>·432 8:<0.0 ø 0.0>·ø}, DRB.IPVolDl.QCI={5:55 7:ø 9:99}, S1SIG.ConnEstabAtt=123)"
assert repr(m['DRB.IPLatDl.3']) == "Stat(0.0, 33.0, 0.0, 123, dtype=float64)"
s = str(m) s = str(m)
assert s[0] == '(' assert s[0] == '('
assert s[-1] == ')' assert s[-1] == ')'
...@@ -77,6 +93,7 @@ def test_Measurement(): ...@@ -77,6 +93,7 @@ def test_Measurement():
vok[m.dtype.names.index("RRC.ConnEstabAtt.sum")] = "17" vok[m.dtype.names.index("RRC.ConnEstabAtt.sum")] = "17"
vok[m.dtype.names.index("S1SIG.ConnEstabAtt")] = "123" vok[m.dtype.names.index("S1SIG.ConnEstabAtt")] = "123"
vok[m.dtype.names.index("DRB.IPVolDl.QCI")] = "{5:55 7:ø 9:99}" vok[m.dtype.names.index("DRB.IPVolDl.QCI")] = "{5:55 7:ø 9:99}"
vok[m.dtype.names.index("DRB.IPLatDl.QCI")] = "{3:<0.0 33.0 0.0>·123 4:<0.0 44.0 0.0>·432 8:<0.0 ø 0.0>·ø}"
assert v == vok assert v == vok
# verify that time fields has enough precision # verify that time fields has enough precision
...@@ -506,12 +523,14 @@ def test_Calc_aggregate(): ...@@ -506,12 +523,14 @@ def test_Calc_aggregate():
m1['X.δT'] = 2 m1['X.δT'] = 2
m1['S1SIG.ConnEstabAtt'] = 12 # Tcc m1['S1SIG.ConnEstabAtt'] = 12 # Tcc
m1['ERAB.SessionTimeUE'] = 1.2 # Ttime m1['ERAB.SessionTimeUE'] = 1.2 # Ttime
m1['DRB.IPLatDl.7'] = Stat(4*ms, 7.32*ms, 25*ms, 17) # Stat
m2 = Measurement() m2 = Measurement()
m2['X.Tstart'] = 5 # NOTE [3,5) is NA hole m2['X.Tstart'] = 5 # NOTE [3,5) is NA hole
m2['X.δT'] = 3 m2['X.δT'] = 3
m2['S1SIG.ConnEstabAtt'] = 11 m2['S1SIG.ConnEstabAtt'] = 11
m2['ERAB.SessionTimeUE'] = 0.7 m2['ERAB.SessionTimeUE'] = 0.7
m2['DRB.IPLatDl.7'] = Stat(3*ms, 5.23*ms, 11*ms, 11)
mlog.append(m1) mlog.append(m1)
mlog.append(m2) mlog.append(m2)
...@@ -532,6 +551,9 @@ def test_Calc_aggregate(): ...@@ -532,6 +551,9 @@ def test_Calc_aggregate():
assert M['ERAB.SessionTimeUE']['value'] == 1.2 + 0.7 assert M['ERAB.SessionTimeUE']['value'] == 1.2 + 0.7
assert M['ERAB.SessionTimeUE']['τ_na'] == 5 assert M['ERAB.SessionTimeUE']['τ_na'] == 5
assert M['DRB.IPLatDl.7']['value'] == Stat(3*ms, (7.32*17 + 5.23*11)/(17+11)*ms, 25*ms, 17+11)
assert M['DRB.IPLatDl.7']['τ_na'] == 5
# assert that everything else is NA with τ_na == 10 # assert that everything else is NA with τ_na == 10
def _(name): def _(name):
...@@ -542,7 +564,7 @@ def test_Calc_aggregate(): ...@@ -542,7 +564,7 @@ def test_Calc_aggregate():
assert f['τ_na'] == 10 assert f['τ_na'] == 10
for name in M.dtype.names: for name in M.dtype.names:
if name not in ('X.Tstart', 'X.δT', 'S1SIG.ConnEstabAtt', if name not in ('X.Tstart', 'X.δT', 'S1SIG.ConnEstabAtt',
'ERAB.SessionTimeUE'): 'ERAB.SessionTimeUE', 'DRB.IPLatDl.7'):
_(name) _(name)
......
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