Commit eb9f1fa9 authored by Paul Graydon's avatar Paul Graydon

amari.kpi: Add DRB.UEActive measurement

parent 7164e99c
......@@ -29,6 +29,8 @@ from xlte import kpi
from xlte.amari import xlog
from golang import func
from math import floor
# LogMeasure takes enb.xlog (TODO and enb.log) as input, and produces kpi.Measurements on output.
#
......@@ -205,10 +207,14 @@ def _read(logm):
# _handle_stats handles next stats xlog entry upon _read request.
@func(LogMeasure)
def _handle_stats(logm, stats: xlog.Message, m_prev: kpi.Measurement):
# build Measurement from stats' counters.
# build Measurement from stats' values, by mapping Amarisoft non-counters
# (non-cumulative) and counters to their respective 3GPP value specified by
# kpi.Measurement.
#
# for non-counters, simply map the value to the corresponding 3GPP value.
#
# we take δ(stats_prev, stat) and process it mapping Amarisoft counters to
# 3GPP ones specified by kpi.Measurement. This approach has following limitations:
# for cumulative counters, we take δ(stats_prev, stat) and process it
# before performing the mapping. This approach has following limitations:
#
# - for most of the counters there is no direct mapping in between
# Amarisoft and 3GPP. For example we currently use s1_erab_setup_request for
......@@ -302,6 +308,17 @@ def _handle_stats(logm, stats: xlog.Message, m_prev: kpi.Measurement):
if m[fini] > m[init]:
m[fini] = m[init]
cells = set(stats['cells'].keys()) # NOTE cells are taken only from stats, not from stat_prev
# map non-counter values, which are independent between periods
# and do not need any special processing.
Σue_active_count_avg = 0
for cell in cells:
Σue_active_count_avg += _stats_cell_nc(stats, cell, 'ue_active_count_avg')
# flooring the sum of averages is more accurate to the 3GPP spec than summing the floors.
Σue_active_count_avg = int(floor(Σue_active_count_avg))
m['DRB.UEActive'] = Σue_active_count_avg
# compute δ for counters.
# any logic error in data will be reported via LogError.
try:
......@@ -315,7 +332,6 @@ def _handle_stats(logm, stats: xlog.Message, m_prev: kpi.Measurement):
# same whether we do aggregation here or in kpi.Calc.erab_accessibility().
#
# TODO rework to emit per-cell measurements when/if we need per-cell KPIs
cells = set(stats['cells'].keys()) # NOTE cells are taken only from stats, not from stat_prev
δΣcell_rrc_connection_request = 0 # (if a cell disappears its counters stop to be accounted)
δΣcell_rrc_connection_setup_complete = 0
for cell in cells:
......@@ -368,12 +384,25 @@ def _stats_check(stats: xlog.Message):
raise LogError(stats.timestamp, "stats: %s" % e) from None
return
# _stats_nc returns specified global non-counter from stats result.
#
# stats is assumed to be already verified by _stats_check.
def _stats_nc(stats: xlog.Message, non_counter: str):
return stats.get(non_counter, 0.0)
# _stats_cc returns specified global cumulative counter from stats result.
#
# stats is assumed to be already verified by _stats_check.
def _stats_cc(stats: xlog.Message, counter: str):
return stats['counters']['messages'].get(counter, 0)
# _stats_cell_nc is like _stats_nc but returns specified per-cell non-counter from stats result.
def _stats_cell_nc(stats: xlog.Message, cell: str, non_counter: str):
_ = stats['cells'].get(cell)
if _ is None:
return 0.0 # cell is absent in this stats
return _.get(non_counter, 0.0)
# _stats_cell_cc is like _stats_cc but returns specified per-cell cumulative counter from stats result.
def _stats_cell_cc(stats: xlog.Message, cell: str, counter: str):
_ = stats['cells'].get(cell)
......
......@@ -85,6 +85,7 @@ class tLogMeasure:
'ERAB.EstabInitSuccNbr.sum',
'ERAB.EstabAddAttNbr.sum',
'ERAB.EstabAddSuccNbr.sum',
'DRB.UEActive'
):
t._mok[field] = 0
......@@ -143,7 +144,7 @@ def test_LogMeasure():
_ = t.expect1
# empty stats after first attach
t.xlog( jstats(1, {}) )
t.xlog( jstats(1, {}, {}) )
_('X.Tstart', 0.02)
_('X.δT', 1-0.02)
t.expect_nodata()
......@@ -152,7 +153,7 @@ def test_LogMeasure():
# tstats is the verb to check handling of stats message.
#
# it xlogs next stats(counters) and reads back new measurement via t.read().
# it xlogs next stats(non_counters, counters) and reads back new measurement via t.read().
#
# NOTE t.read goes 2 steps behind corresponding t.xlog call. This is on
# purpose to sync emitting xlog entries with corresponding checks in test
......@@ -166,17 +167,17 @@ def test_LogMeasure():
#
# As the result it allows to write testing code as:
#
# tstats(counters)
# tstats(non_counters, counters)
# _(...) # verify effect on Measurements returned with period
# _(...) # ending by timestamp of the above stats call.
# _(...) # i.e. Measurement₁ if tstats call corresponds to xlog₂.
τ_xlog = 1 # timestamp of last emitted xlog entry
τ_logm = τ_xlog-2+1 # timestamp of next measurement to be read from logm
counters_prev = {}
def tstats(counters):
def tstats(non_counters, counters):
nonlocal τ_xlog, τ_logm, counters_prev
trace('\n>>> tstats τ_xlog: %s τ_logm: %s' % (τ_xlog, τ_logm))
t.xlog( jstats(τ_xlog+1, counters) ) # xlog τ+1
t.xlog( jstats(τ_xlog+1, non_counters, counters) ) # xlog τ+1
t.read() # read+assert M for τ-1
_('X.Tstart', τ_logm+1) # start preparing next expected M at τ
_('X.δT', 1)
......@@ -189,7 +190,7 @@ def test_LogMeasure():
counters = counters_prev.copy()
for k,δv in δcounters.items():
counters[k] = counters.get(k,0) + δv
tstats(counters)
tstats({}, counters)
# tevent is the verb to verify handling of events.
# its logic is similar to tstats.
......@@ -217,9 +218,8 @@ def test_LogMeasure():
t.xlog( jdrb_stats(τ, qci_trx) )
# further empty stats
tstats({})
tstats({}, {})
_('X.Tstart', 1)
_('X.δT', 1)
_('RRC.ConnEstabAtt.sum', 0)
......@@ -230,6 +230,21 @@ def test_LogMeasure():
_('ERAB.EstabInitSuccNbr.sum', 0)
_('ERAB.EstabAddAttNbr.sum', 0)
_('ERAB.EstabAddSuccNbr.sum', 0)
_('DRB.UEActive', 0)
# DRB.UEActive
#
# For non-counter statistics, simply check that
# the value of each period is correct
tstats({'C1.ue_active_count_avg': 0}, {})
_('DRB.UEActive', 0)
tstats({'C1.ue_active_count_avg': 0.287}, {})
_('DRB.UEActive', 0)
tstats({'C1.ue_active_count_avg': 1.089}, {})
_('DRB.UEActive', 1)
# RRC.ConnEstab
......@@ -245,28 +260,28 @@ def test_LogMeasure():
# init 0 3 2 5 0
# fini ø ←─── 2 1←─── 2←─── 4←─── 1
# fini' 0 3 ² 2 ² 3 ¹ 0
tstats({'C1.rrc_connection_request': 0,
'C1.rrc_connection_setup_complete': 2}) # completions for previous uncovered period
tstats({}, {'C1.rrc_connection_request': 0,
'C1.rrc_connection_setup_complete': 2}) # completions for previous uncovered period
_('RRC.ConnEstabAtt.sum', 0)
_('RRC.ConnEstabSucc.sum', 0) # not 2
# p2
tstats({'C1.rrc_connection_request': 0 +3, # 3 new initiations
'C1.rrc_connection_setup_complete': 2 +1}) # 1 new completion
tstats({}, {'C1.rrc_connection_request': 0 +3, # 3 new initiations
'C1.rrc_connection_setup_complete': 2 +1}) # 1 new completion
_('RRC.ConnEstabAtt.sum', 3)
_('RRC.ConnEstabSucc.sum', 3) # not 1
# p3
tstats({'C1.rrc_connection_request': 0+3 +2, # 2 new initiations
'C1.rrc_connection_setup_complete': 2+1 +2}) # 2 completions for p2
tstats({}, {'C1.rrc_connection_request': 0+3 +2, # 2 new initiations
'C1.rrc_connection_setup_complete': 2+1 +2}) # 2 completions for p2
_('RRC.ConnEstabAtt.sum', 2)
_('RRC.ConnEstabSucc.sum', 2) # 2, but it is 2 - 2(for_p2) + 2(from_p4)
# p4
tstats({'C1.rrc_connection_request': 0+3+2 +5, # 5 new initiations
'C1.rrc_connection_setup_complete': 2+1+2 +4}) # 2 completions for p3 + 2 new
tstats({}, {'C1.rrc_connection_request': 0+3+2 +5, # 5 new initiations
'C1.rrc_connection_setup_complete': 2+1+2 +4}) # 2 completions for p3 + 2 new
_('RRC.ConnEstabAtt.sum', 5)
_('RRC.ConnEstabSucc.sum', 3)
# p5
tstats({'C1.rrc_connection_request': 0+3+2+5 +0, # no new initiations
'C1.rrc_connection_setup_complete': 2+1+2+4 +1}) # 1 completion for p4
tstats({}, {'C1.rrc_connection_request': 0+3+2+5 +0, # no new initiations
'C1.rrc_connection_setup_complete': 2+1+2+4 +1}) # 1 completion for p4
_('RRC.ConnEstabAtt.sum', 0)
_('RRC.ConnEstabSucc.sum', 0)
......@@ -407,10 +422,10 @@ def test_LogMeasure():
tevent("service attach")
t.expect_nodata()
t.xlog( jstats(τ_xlog+1, {i:1000, f:1000}) ) # LogMeasure restarts the queue after data starts to
t.xlog( jstats(τ_xlog+1, {}, {i:1000, f:1000}) ) # LogMeasure restarts the queue after data starts to
τ_xlog += 1 # come in again. Do one t.xlog step manually to
# increase t.read - t.xlog distance back to 2.
tstats({i:1000+2, f:1000+2})
tstats({}, {i:1000+2, f:1000+2})
_(I, 2) # no "extra" events even if counters start with jumped values after reattach
_(F, 2) # and no fini correction going back through detach
......@@ -424,31 +439,41 @@ def test_LogMeasure():
# multiple cells
# TODO emit per-cell measurements instead of accumulating all cells
tstats({})
tstats({}, {})
t.expect_nodata()
tstats({})
tstats({}, {})
_('DRB.UEActive', 0)
_('RRC.ConnEstabAtt.sum', 0)
_('RRC.ConnEstabSucc.sum', 0)
# C1 appears
tstats({'C1.rrc_connection_request': 12, 'C1.rrc_connection_setup_complete': 11})
tstats({'C1.ue_active_count_avg': 3.59},
{'C1.rrc_connection_request': 12, 'C1.rrc_connection_setup_complete': 11})
_('DRB.UEActive', 3) # floor(3.59)
_('RRC.ConnEstabAtt.sum', 12)
_('RRC.ConnEstabSucc.sum', 11+1)
# C2 appears
tstats({'C1.rrc_connection_request': 12+3, 'C1.rrc_connection_setup_complete': 11+3,
'C2.rrc_connection_request': 22, 'C2.rrc_connection_setup_complete': 21})
tstats({'C1.ue_active_count_avg': 2.87, 'C2.ue_active_count_avg': 1.43},
{'C1.rrc_connection_request': 12+3, 'C1.rrc_connection_setup_complete': 11+3,
'C2.rrc_connection_request': 22, 'C2.rrc_connection_setup_complete': 21})
_('DRB.UEActive', 4) # floor(2.87+1.43)
_('RRC.ConnEstabAtt.sum', 3+22)
_('RRC.ConnEstabSucc.sum', -1+3+21+2)
# C1 and C2 stays
tstats({'C1.rrc_connection_request': 12+3+3, 'C1.rrc_connection_setup_complete': 11+3+3,
'C2.rrc_connection_request': 22+4, 'C2.rrc_connection_setup_complete': 21+4})
tstats({'C1.ue_active_count_avg': 3.10, 'C2.ue_active_count_avg': 0.62},
{'C1.rrc_connection_request': 12+3+3, 'C1.rrc_connection_setup_complete': 11+3+3,
'C2.rrc_connection_request': 22+4, 'C2.rrc_connection_setup_complete': 21+4})
_('DRB.UEActive', 3) # floor(3.10+0.62)
_('RRC.ConnEstabAtt.sum', 3+4)
_('RRC.ConnEstabSucc.sum', -2+3+4+2)
# C1 disappears
tstats({'C2.rrc_connection_request': 22+4+4, 'C2.rrc_connection_setup_complete': 21+4+4})
tstats({'C2.ue_active_count_avg': 1.19},
{'C2.rrc_connection_request': 22+4+4, 'C2.rrc_connection_setup_complete': 21+4+4})
_('DRB.UEActive', 1) # floor(1.19)
_('RRC.ConnEstabAtt.sum', 4)
_('RRC.ConnEstabSucc.sum', 4-2)
# C2 disappears
tstats({})
tstats({}, {})
_('DRB.UEActive', 0)
_('RRC.ConnEstabAtt.sum', 0)
_('RRC.ConnEstabSucc.sum', 0)
......@@ -467,28 +492,28 @@ def test_LogMeasure_badinput():
CC = 'RRC.ConnEstabAtt.sum'
# initial ok entries
t.xlog( jstats(1, {}) )
t.xlog( jstats(2, {cc: 2}) )
t.xlog( jstats(3, {cc: 2+3}) )
t.xlog( jstats(1, {}, {}) )
t.xlog( jstats(2, {}, {cc: 2}) )
t.xlog( jstats(3, {}, {cc: 2+3}) )
# bad: no counters
t.xlog('{"message":"stats", "utc":21, "counters": {"messages": {}}, "cells": {"1": {}}}')
t.xlog('{"message":"stats", "utc":22, "counters": {"messages": {}}, "cells": {"1": {"counters": {}}}}')
t.xlog('{"message":"stats", "utc":23, "cells": {"1": {"counters": {"messages": {}}}}}')
t.xlog('{"message":"stats", "utc":24, "counters": {}, "cells": {"1": {"counters": {"messages": {}}}}}')
# follow-up ok entries
t.xlog( jstats(31, {cc: 30+4}) )
t.xlog( jstats(32, {cc: 30+4+5}) )
t.xlog( jstats(31, {}, {cc: 30+4}) )
t.xlog( jstats(32, {}, {cc: 30+4+5}) )
# badline 1
t.xlog( "zzzqqqrrr" )
# more ok entries
t.xlog( jstats(41, {cc: 40+6}) )
t.xlog( jstats(42, {cc: 40+6+7}) )
t.xlog( jstats(41, {}, {cc: 40+6}) )
t.xlog( jstats(42, {}, {cc: 40+6+7}) )
# badline 2 + followup event
t.xlog( "hello world" )
t.xlog( '{"meta": {"event": "service attach", "time": 50}}' )
# more ok entries
t.xlog( jstats(51, {cc: 50+8}) )
t.xlog( jstats(52, {cc: 50+8+9}) )
t.xlog( jstats(51, {}, {cc: 50+8}) )
t.xlog( jstats(52, {}, {cc: 50+8+9}) )
def readok(τ, CC_value):
_('X.Tstart', τ)
......@@ -540,11 +565,11 @@ def test_LogMeasure_cc_wraparound():
cc = 'C1.rrc_connection_request'
CC = 'RRC.ConnEstabAtt.sum'
t.xlog( jstats(1, {}) )
t.xlog( jstats(2, {cc: 13}) )
t.xlog( jstats(3, {cc: 12}) ) # cc↓ - should be reported
t.xlog( jstats(4, {cc: 140}) ) # cc↑↑ - should start afresh
t.xlog( jstats(5, {cc: 150}) )
t.xlog( jstats(1, {}, {}) )
t.xlog( jstats(2, {}, {cc: 13}) )
t.xlog( jstats(3, {}, {cc: 12}) ) # cc↓ - should be reported
t.xlog( jstats(4, {}, {cc: 140}) ) # cc↑↑ - should start afresh
t.xlog( jstats(5, {}, {cc: 150}) )
def readok(τ, CC_value):
_('X.Tstart', τ)
......@@ -574,10 +599,10 @@ def test_LogMeasure_sync():
cc = 'C1.rrc_connection_request'
CC = 'RRC.ConnEstabAtt.sum'
t.xlog( jstats(1, {}) )
t.xlog( jstats(2, {cc: 4}) )
t.xlog( jstats(1, {}, {}) )
t.xlog( jstats(2, {}, {cc: 4}) )
t.xlog( '{"meta": {"event": "sync", "time": 2.5, "state": "attached", "reason": "periodic", "generator": "xlog ws://localhost:9001 stats[]/30.0s"}}' )
t.xlog( jstats(3, {cc: 7}) )
t.xlog( jstats(3, {}, {cc: 7}) )
def readok(τ, CC_value):
_('X.Tstart', τ)
......@@ -593,29 +618,49 @@ def test_LogMeasure_sync():
readok(2, 3) # 2-3 jumping over sync
# jstats returns json-encoded stats message corresponding to counters dict.
# jstats returns json-encoded stats message corresponding to non-counters and counters dicts.
#
# if a counter goes as "Cxxx.yyy" it is emitted as counter yyy of cell xxx in the output.
# if a non-counter or counter goes as "Cxxx.yyy" it is emitted as value yyy of cell xxx in the output.
# τ goes directly to stats['utc'] as is.
def jstats(τ, counters): # -> str
def jstats(τ, non_counters, counters): # -> str
g_nc = {} # global non-counters
g_cc = {} # global cumulative counters
cells = {} # .cells
for cc, value in counters.items():
_ = re.match(r"^C([^.]+)\.(.+)$", cc)
for nc, value in non_counters.items():
_ = re.match(r"^C([^.]+)\.(.+)$", nc)
if _ is not None:
cell = _.group(1)
cc = _.group(2)
cells.setdefault(cell, {}) \
.setdefault("counters", {}) \
.setdefault("messages", {}) \
[cc] = value
nc = _.group(2)
cells.setdefault(cell, {}) \
[nc] = value
else:
g_cc[cc] = value
g_nc[nc] = value
# Keep a correct structure in every cell
# in which a non-counter value is present
for cell in cells:
cells.setdefault(cell, {}) \
.setdefault("counters", {}) \
.setdefault("messages", {})
if counters:
for cc, value in counters.items():
_ = re.match(r"^C([^.]+)\.(.+)$", cc)
if _ is not None:
cell = _.group(1)
cc = _.group(2)
cells.setdefault(cell, {}) \
.setdefault("counters", {}) \
.setdefault("messages", {}) \
[cc] = value
else:
g_cc[cc] = value
s = {
"message": "stats",
"utc": τ,
**g_nc,
"cells": cells,
"counters": {"messages": g_cc},
}
......@@ -623,17 +668,32 @@ def jstats(τ, counters): # -> str
return json.dumps(s)
def test_jstats():
assert jstats(0, {}) == '{"message": "stats", "utc": 0, "cells": {}, "counters": {"messages": {}}}'
assert jstats(123.4, {"C1.rrc_x": 1, "s1_y": 2, "C1.rrc_z": 3, "x2_zz": 4}) == \
'{"message": "stats", "utc": 123.4, "cells": {"1": {"counters": {"messages": {"rrc_x": 1, "rrc_z": 3}}}}, "counters": {"messages": {"s1_y": 2, "x2_zz": 4}}}'
assert jstats(0, {}, {}) == '{"message": "stats", "utc": 0, "cells": {}, "counters": {"messages": {}}}'
# only non-counters
assert jstats(1.2, {"C1.ue_x": 1, "r1_y": 2, "C1.ue_z": 3, "s2_zz": 4}, {}) == \
'{"message": "stats", "utc": 1.2, "r1_y": 2, "s2_zz": 4, "cells": {"1": {"ue_x": 1, "ue_z": 3, "counters": {"messages": {}}}}, "counters": {"messages": {}}}'
# only counters
assert jstats(12.3, {}, {"C1.rrc_x": 1, "s1_y": 2, "C1.rrc_z": 3, "x2_zz": 4}) == \
'{"message": "stats", "utc": 12.3, "cells": {"1": {"counters": {"messages": {"rrc_x": 1, "rrc_z": 3}}}}, "counters": {"messages": {"s1_y": 2, "x2_zz": 4}}}'
# both non-counters and counters
assert jstats(123.4, {"C1.ue_w": 1, "r1_ww": 2, "C1.ue_x": 3, "s2_xx": 4}, {"C1.rrc_y": 5, "s1_yy": 6, "C1.rrc_z": 7, "x2_zz": 8}) == \
'{"message": "stats", "utc": 123.4, ' + \
'"r1_ww": 2, "s2_xx": 4, ' + \
'"cells": {"1": {"ue_w": 1, "ue_x": 3, "counters": {"messages": {"rrc_y": 5, "rrc_z": 7}}}}, ' + \
'"counters": {"messages": {"s1_yy": 6, "x2_zz": 8}}}'
# multiple cells
assert jstats(432.1, {"C1.rrc_x": 11, "C2.rrc_y": 22, "C3.xyz": 33, "C1.abc": 111, "xyz": 44}) == \
'{"message": "stats", "utc": 432.1, "cells": {' + \
'"1": {"counters": {"messages": {"rrc_x": 11, "abc": 111}}}, ' + \
'"2": {"counters": {"messages": {"rrc_y": 22}}}, ' + \
'"3": {"counters": {"messages": {"xyz": 33}}}}, ' + \
'"counters": {"messages": {"xyz": 44}}}'
assert jstats(432.1,
{"C1.ue_w": 11, "C2.ue_ww": 22, "C3.ue_x": 33, "C1.ue_xx": 111, "rst": 44},
{"C1.rrc_y": 55, "C2.rrc_yy": 66, "C3.xyz": 77, "C1.abc": 222, "xyz": 88}) == \
'{"message": "stats", "utc": 432.1, "rst": 44, "cells": {' + \
'"1": {"ue_w": 11, "ue_xx": 111, "counters": {"messages": {"rrc_y": 55, "abc": 222}}}, ' + \
'"2": {"ue_ww": 22, "counters": {"messages": {"rrc_yy": 66}}}, ' + \
'"3": {"ue_x": 33, "counters": {"messages": {"xyz": 77}}}}, ' + \
'"counters": {"messages": {"xyz": 88}}}'
# jdrb_stats, similarly to jstats, returns json-encoded x.drb_stats message
......
......@@ -179,6 +179,9 @@ class Measurement(np.void):
('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.UEActive', np.int32), # 1 4.4.2.4 36.314:4.1.3.3
# 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
......
......@@ -53,6 +53,8 @@ def test_Measurement():
assert m['S1SIG.ConnEstabAtt'] == 123
m['RRC.ConnEstabAtt.sum'] = 17
assert m['RRC.ConnEstabAtt.sum'] == 17
m['DRB.UEActive'] = 6
assert m['DRB.UEActive'] == 6
m['DRB.IPVolDl.QCI'][:] = 0
m['DRB.IPVolDl.5'] = 55
m['DRB.IPVolDl.7'] = NA(m['DRB.IPVolDl.7'].dtype)
......@@ -67,13 +69,14 @@ def test_Measurement():
assert m['DRB.IPVolDl.QCI'][k] == 0
# 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.UEActive=6, DRB.IPVolDl.QCI={5:55 7:ø 9:99}, S1SIG.ConnEstabAtt=123)"
s = str(m)
assert s[0] == '('
assert s[-1] == ')'
v = s[1:-1].split(', ')
vok = ['ø'] * len(m._dtype0.names)
vok[m.dtype.names.index("RRC.ConnEstabAtt.sum")] = "17"
vok[m.dtype.names.index("DRB.UEActive")] = "6"
vok[m.dtype.names.index("S1SIG.ConnEstabAtt")] = "123"
vok[m.dtype.names.index("DRB.IPVolDl.QCI")] = "{5:55 7:ø 9:99}"
assert v == vok
......
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