E2SM-KPM — Key Performance Measurement
E2SM-KPM is the measurement axis: aggregated performance counters — throughput, PRB usage, delay — reported per cell or per UE. This guide shows how to discover what a node measures, subscribe to it, read the indications, and take a one-shot snapshot, in Python, Go, and C.
Spec anchors
- O-RAN.WG3.E2SM-KPM v03.00 — the contract implemented here.
- OID
1.3.6.1.4.1.53148.1.3.2.2, default RAN Function ID2. - REPORT-only per spec (no INSERT / CONTROL / POLICY).
What KPM gives you
| Service | Implemented | Use |
|---|---|---|
| REPORT | Styles 1–5 | Periodic cell- or UE-level measurement reports |
| One-shot read | kpm_snapshot |
Subscribe, take the first report, unsubscribe — a client-side convenience, not a wire procedure |
The five REPORT styles
All five v03.00 styles are implemented. A style fixes the reporting
scope and what conditions it requires; per-style cross-field validation
runs at submission time, so an invalid combination fails on subscribe,
not on the wire.
| Style | Scope | UE selection | Conditions |
|---|---|---|---|
| 1 | Cell-level | — | — |
| 2 | Single-UE | exactly 1 UE ID | — |
| 3 | Per-measurement, UE-level | matched by condition | per-measurement |
| 4 | Common condition, UE-level | matched by condition | ≥1 global condition |
| 5 | Multi-UE | ≥2 UE IDs | — |
Style 4 (per-UE, filtered by a common condition such as an S-NSSAI slice) is the workhorse; Style 1 (cell-level counters) is the other common choice.
Step 1 — discover what the node measures
A KPM node advertises its report styles and named measurements in its RAN Function Definition. Resolve the function by OID — never by a hard-coded RAN Function ID — then decode the definition and subscribe only to what the node actually offers. This is what makes an xApp work against a gNB it was not written for.
from airpuls_ric_sdk import KPM_OID, decode_kpm_ran_function
def on_e2_node_available(self, client, node):
fn = node.find_ran_function_by_oid(KPM_OID)
if fn is None:
return # this node has no KPM
desc = decode_kpm_ran_function(fn.definition_bytes)
if desc is None:
return # definition did not decode
for style in desc.report_styles:
print(style.style_type, style.style_name, len(style.measurements))
measurements = desc.all_measurements # union across styles
fn := node.FindRanFunctionByOID(ric.KpmOID)
if fn == nil {
return
}
desc, err := ric.DecodeRanFunctionDefinition(ric.KpmPluginGet(),
fn.DefinitionBytes())
if err != nil {
return
}
defer desc.Free()
for i := 0; i < desc.ReportStyleCount(); i++ {
style := desc.ReportStyleAt(i)
for j := 0; j < style.MeasCount(); j++ {
log.Printf("style %d: %s", style.Type(), style.MeasNameAt(j))
}
}
DecodeRanFunctionDefinition is generic — pass any SM's plugin.
#include "e2sm-kpm.h"
const ric_ran_function_t *fn =
ric_e2_node_find_ran_function_by_oid(node, KPM_OID);
if (!fn) return;
size_t len = 0;
const uint8_t *def = ric_ran_function_definition_bytes(fn, &len);
sm_ran_function_description_t *desc = NULL;
if (kpm_ran_function_definition_decode(def, len, &desc) != 0) return;
for (size_t i = 0; i < sm_ran_function_description_report_style_count(desc); i++) {
const sm_report_style_info_t *st =
sm_ran_function_description_report_style_at(desc, i);
for (size_t j = 0; j < sm_report_style_meas_count(st); j++)
printf("style %lld: %s\n", (long long)sm_report_style_type(st),
sm_report_style_meas_name_at(st, j));
}
sm_ran_function_description_free(desc);
The bundled kpm-monitor subscribes to every advertised measurement
this way — a good starting pattern.
What the airpuls E2 agent reports
KPM names are not fixed by the SDK — they are whatever the node advertises, which is why the discovery step above is not optional. For orientation, the airpuls E2 agent and the E2 emulator both advertise this set (3GPP TS 28.552 names):
| Measurement | Scope | Meaning |
|---|---|---|
DRB.UEThpDl / DRB.UEThpUl |
per UE | UE throughput, downlink / uplink |
DRB.PdcpSduVolumeDL / …UL |
per UE | PDCP SDU volume |
DRB.RlcSduTransmittedVolumeDL / …UL |
per UE | RLC SDU transmitted volume |
DRB.RlcSduDelayDl |
style-dependent | RLC SDU delay |
RRU.PrbTotDl / RRU.PrbTotUl |
style-dependent | PRB utilisation, as a percentage |
RRU.PrbUsedDl / RRU.PrbUsedUl |
style-dependent | Mean PRBs used per slot |
The last three rows carry the same measurement name at two scopes — the
REPORT style decides which one you receive. TS 28.552 defines them
cell-wide (object class NRCellDU); E2SM-KPM §7.9.0 defines their
UE-level counterparts as the same formulas "with restriction to the
individual UE", and Styles 2–4 are UE-level. So a Style 4 subscription
returns this UE's PRB share, per-slot PRB usage and mean SDU delay
on every UE block, while a Style 1 subscription returns the cell-wide
originals. The airpuls agent advertises both styles with per-style
measurement lists (RRU.PrbAvail* is Style 1-only), and a Style 1
subscription must name a served cell with
set_cell_global_id() — a missing or foreign CGI is rejected at
subscribe time.
A different gNB advertises a different set — never hard-code this
table, subscribe to what the definition decode returned. A name the
node does not advertise is rejected at subscribe time with a
MEASUREMENT_NOT_ADVERTISED notification, not silently dropped.
Step 2 — subscribe
Build a subscription with the KPM builder: pick a style, set the report period and granularity (ms), add measurement names, and — for Style 4 — add at least one condition. The builder is single-use: the subscribe call consumes it.
from airpuls_ric_sdk import KpmSubscriptionBuilder, KpmStyle, KpmConditionExpr
# Cell-level (Style 1)
b = KpmSubscriptionBuilder(KpmStyle.STYLE_1)
b.set_report_period(1000)
b.set_granularity(1000)
b.add_measurement("RRU.PrbTotDl")
b.add_measurement("RRU.PrbTotUl")
sub_id = client.subscribe_kpm(node, b)
# Per-UE (Style 4 — requires ≥1 condition)
b = KpmSubscriptionBuilder(KpmStyle.STYLE_4)
b.set_report_period(1000)
b.set_granularity(1000)
b.add_measurement("DRB.UEThpDl")
b.add_measurement("DRB.UEThpUl")
b.add_condition_snssai_sst(KpmConditionExpr.EQUAL, 1)
sub_id = client.subscribe_kpm(node, b)
// Cell-level (Style 1)
b := ric.NewKpmSubscriptionBuilder(ric.KpmStyle1)
b.SetReportPeriod(1000).
SetGranularity(1000).
AddMeasurement("RRU.PrbTotDl").
AddMeasurement("RRU.PrbTotUl")
subID, err := client.SubscribeKPM(node, b)
// Per-UE (Style 4 — requires ≥1 condition)
b = ric.NewKpmSubscriptionBuilder(ric.KpmStyle4)
b.SetReportPeriod(1000).
SetGranularity(1000).
AddMeasurement("DRB.UEThpDl").
AddConditionSnssaiSST(ric.KpmExprEqual, 1)
subID, err = client.SubscribeKPM(node, b)
Builders are consumed by SubscribeKPM; further use returns
ric.ErrBuilderConsumed.
#include "e2sm-kpm.h"
/* Cell-level (Style 1) */
kpm_subscription_builder_t *b = kpm_subscription_builder_new(KPM_STYLE_1);
kpm_sub_set_report_period(b, 1000);
kpm_sub_set_granularity(b, 1000);
kpm_sub_add_measurement(b, "RRU.PrbTotDl");
kpm_sub_add_measurement(b, "RRU.PrbTotUl");
uint32_t sub_id = 0;
ric_client_subscribe_kpm(client, node, b, &sub_id); /* consumes b */
/* Per-UE (Style 4 — requires ≥1 condition) */
kpm_subscription_builder_t *b4 = kpm_subscription_builder_new(KPM_STYLE_4);
kpm_sub_set_report_period(b4, 1000);
kpm_sub_set_granularity(b4, 1000);
kpm_sub_add_measurement(b4, "DRB.UEThpDl");
kpm_sub_add_condition_snssai_sst(b4, KPM_EXPR_EQUAL, 1);
ric_client_subscribe_kpm(client, node, b4, &sub_id);
Selecting UEs by identity (Styles 2 and 5)
Styles 2 and 5 name the UEs instead of matching them by condition — Style 2 takes exactly one, Style 5 two or more. There is one adder per UEID flavour, matching the E2 node type the UE is known to:
| UEID | Python | Go | C |
|---|---|---|---|
| gNB (AMF UE NGAP ID) | add_ue_id(id, plmn) |
AddUeID(id, plmn) |
kpm_sub_add_ue_id(b, id, plmn) |
| gNB-DU (F1AP ID) | add_ue_id_gnb_du(id) |
AddUeIDGnbDu(id) |
kpm_sub_add_ue_id_gnb_du(b, id) |
| gNB-CU-UP (E1AP ID) | add_ue_id_gnb_cu_up(id) |
AddUeIDGnbCuUp(id) |
kpm_sub_add_ue_id_gnb_cu_up(b, id) |
| ng-eNB / ng-eNB-DU / en-gNB / eNB | add_ue_id_ng_enb(…), …_ng_enb_du(…), …_en_gnb(…), …_enb(…) |
same names, Go-cased | kpm_sub_add_ue_id_* |
b = KpmSubscriptionBuilder(KpmStyle.STYLE_2)
b.set_report_period(1000)
b.add_measurement("DRB.UEThpDl")
b.add_ue_id(amf_ue_ngap_id=12345, plmn=b"\x00\xf1\x10") # exactly one
sub_id = client.subscribe_kpm(node, b)
Getting a UE ID to name is the bootstrap problem an RC QUERY solves: query the node for the UEs it already knows, then subscribe by identity.
Test conditions
Conditions filter which UEs a UE-level style reports on. Style 4 applies them globally; Style 3 applies them per measurement, so one subscription can report different measurements over different UE populations.
| Condition type | Constant |
|---|---|
| S-NSSAI (slice) | S_NSSAI — use the _snssai_sst / _snssai_sst_sd helpers |
| 5QI / QCI | FIVE_QI, QCI |
| Radio quality | RSRP, RSRQ, UL_RSRP, CQI |
| Bitrate | GBR, AMBR |
| UE class | IS_STAT, IS_CAT_M |
Expressions are EQUAL, GREATER, LESS, CONTAINS, PRESENT.
from airpuls_ric_sdk import KpmConditionType, KpmConditionExpr
# Style 4 — one condition for the whole subscription
b.add_condition_int(KpmConditionType.RSRP, KpmConditionExpr.LESS, -100)
# Style 3 — a condition attached to one measurement
b.add_measurement_condition_snssai_sst("DRB.UEThpDl", KpmConditionExpr.EQUAL, 1)
b.add_measurement_condition_int("DRB.UEThpUl",
KpmConditionType.CQI, KpmConditionExpr.LESS, 7)
Subcounter labels
A MeasurementLabel selects a subcounter dimension — report
DRB.UEThpDl broken down per 5QI, per slice, or as min/max/avg rather
than the aggregate. Add the same measurement several times with
different labels and each produces its own subcounter; labelled entries
are deliberately not deduplicated.
Available dimensions: PLMN, S-NSSAI, 5QI, QFI, QCI (and QCI/ARP ranges),
bitrate range, MU-MIMO layer, SSB index, beam ID, distribution bin, and
the aggregates sum / min / max / avg. On the indication side the
labels come back on each measurement — see below.
Step 3 — read the indications
KPM indications lower into the generic measurement-block tree: one block per UE (or one cell-scoped block), each holding named integer/real measurements. See Building xApps → Reading indication data for the full accessor list.
def on_indication(self, client, sub_id, node, ran_func_id, header, data):
for block in data.blocks:
ue = block.ue_id.display # "ue:amf_ue_ngap_id:12345" or cell-scoped
for m in block.measurements:
print(ue, m.name, m.value) # m.value: int | float | None
for label in m.labels: # subcounter dimensions, if any
print(" ", label.to_dict())
for (size_t bi = 0; bi < sm_indication_data_block_count(data); bi++) {
const sm_measurement_block_t *blk = sm_indication_data_block_at(data, bi);
for (size_t j = 0; j < sm_measurement_block_count(blk); j++) {
const sm_measurement_t *m = sm_measurement_block_at(blk, j);
printf("%s = %lld\n", sm_measurement_name(m),
(long long)sm_measurement_integer(m));
}
}
KPM timestamps are RFC 5905 NTP
The KPM indication header carries a colletStartTime timestamp,
decoded per O-RAN.WG3.E2SM-KPM-v03.00 §8.3.12 (RFC 5905 NTP:
seconds-since-1900 + a 2⁻³² s fraction) and surfaced as
header.timestamp_ms. Carry it into your sinks rather than stamping
at dispatch time — see
Telemetry Sinks → Preserving an external timestamp.
Every Style 4 measurement is per-UE
Style 4 is UE-level, so E2SM-KPM §7.9.0's per-UE conversions apply
to every measurement it carries — including RRU.PrbTot* (this
UE's share of the cell's available PRBs) and DRB.RlcSduDelayDl
(this UE's mean SDU sojourn). Emit them under per-UE tags;
per-UE aggregates such as SUM(RRU.PrbTotDl) BY (node) are
meaningful. For the cell-wide originals, subscribe REPORT
Style 1 instead — one name, two scopes, chosen by style.
One-shot: kpm_snapshot
A late-starting xApp that wants one reading — not a stream — can take a snapshot. It is a client-side state machine, not a wire procedure: it subscribes with the builder you give it, waits for the first indication, unsubscribes, and returns the decoded result. Cost is one full Subscribe round-trip plus at least one report period.
b = KpmSubscriptionBuilder(KpmStyle.STYLE_1)
b.set_report_period(1000)
b.add_measurement("RRU.PrbTotDl")
# Style 1 subscribes cell-confined measurements: name the cell
# (E2SM-KPM §7.4.2.2) — 3-byte BCD PLMN + 36-bit NR Cell Identity.
b.set_cell_global_id(bytes([0x00, 0xF1, 0x10]), 0xA00000003)
snap = client.kpm_snapshot(node, b, timeout_ms=5000) # consumes b
print(snap.timestamp_ms)
for block in snap.blocks:
for m in block.measurements:
print(m.name, m.value)
The result is pure Python — every value is copied out, so there is no C lifetime to manage.
An empty snapshot is a valid result
A Style 5 (or condition-filtered) snapshot may legitimately return zero blocks — that is a successful read with no matching UEs, not an error.
Reference
| Concept | Python | Go | C |
|---|---|---|---|
| Plugin | kpm_plugin_get() |
ric.KpmPluginGet() |
kpm_plugin_get() |
| RAN Function ID | KPM_RAN_FUNC_ID (2) |
ric.KpmRanFuncID |
KPM_RAN_FUNC_ID |
| OID | KPM_OID |
ric.KpmOID |
KPM_OID |
| Decode definition | decode_kpm_ran_function(bytes) |
ric.DecodeRanFunctionDefinition(plugin, bytes) |
kpm_ran_function_definition_decode(...) |
| Builder | KpmSubscriptionBuilder(KpmStyle.STYLE_4) |
ric.NewKpmSubscriptionBuilder(ric.KpmStyle4) |
kpm_subscription_builder_new(KPM_STYLE_4) |
| Period / granularity | set_report_period / set_granularity |
SetReportPeriod / SetGranularity |
kpm_sub_set_report_period / _set_granularity |
| Measurement | add_measurement(name) |
AddMeasurement(name) |
kpm_sub_add_measurement(b, name) |
| Labelled measurement | add_measurement_label(name, label) |
AddMeasurementWithLabel(name, label) |
kpm_sub_add_measurement_label(b, name, label) |
| Condition | add_condition_snssai_sst(expr, sst), add_condition_int(type, expr, v) |
AddConditionSnssaiSST, AddConditionInt |
kpm_sub_add_condition_snssai_sst, _add_condition_int |
| Per-measurement condition | add_measurement_condition_int(...) |
AddMeasurementConditionInt(...) |
kpm_sub_add_measurement_condition_int(...) |
| UE ID (Style 2/5) | add_ue_id(id, plmn) |
AddUeID(id, plmn) |
kpm_sub_add_ue_id(b, id, plmn) |
| Subscribe | client.subscribe_kpm(node, b) |
client.SubscribeKPM(node, b) |
ric_client_subscribe_kpm(client, node, b, &id) |
| One-shot | client.kpm_snapshot(node, b, timeout_ms) |
client.KpmSnapshot(node, b, ms) |
ric_client_kpm_snapshot(...) |
| Unsubscribe | client.unsubscribe(sub_id) |
client.Unsubscribe(subID) |
ric_client_unsubscribe(client, id) |
Bundled examples
kpm-monitor (Python, Go, and C) subscribes Style 4 to every
advertised measurement and streams samples to the sinks; ho-trigger
(Python) drives a handover from a KPM throughput threshold; y1-termination
serves KPM-derived analytics over a Y1 REST API. See
xApps → Monitoring and
Control.
airpuls-sdk xapp new --sm kpm generates a working version of the
discover-then-subscribe pattern above — see the CLI.