E2SM-AIR — per-UE radio measurements
E2SM-AIR is the airpuls sensing SM. It exposes per-UE radio measurements the gNB already computes, over two REPORT styles that face opposite directions:
- Style 1 — the uplink measurements SRS channel estimation produces: wideband and per-RB SNR, SRS-based Timing Advance, and the estimated frequency-domain channel per receive antenna / SRS port. The data source for ISAC (Integrated Sensing And Communication) xApps.
- Style 2 — the link quality the node holds per UE in both directions: downlink, the RSRP and SS-SINR the UE itself measured and reported; uplink, the SNR the node measures on the UE's PUSCH and PUCCH and how far that sits from its power-control target; and in each direction the MCS the scheduler is applying and the block error ratio it is seeing.
The distinction between the styles is the source, not the direction.
Style 1 is what SRS channel estimation produces; Style 2 is what the
MAC scheduler holds. Both carry uplink quantities, and they are not
interchangeable: Style 1's wideband SNR is an SRS signal-to-noise
estimate, while Style 2's AIR.ulPuschSNR is the closed-loop estimate power
control steers on.
Spec anchors
- airpuls vendor SM (
E2SM-AIR-v01.00-airpuls.asn) — not O-RAN-blessed. - OID
1.3.6.1.4.1.99999.3.1.2(placeholder PEN pending IANA), default RAN Function ID101. - Complements E2SM-LLC Style 1: LLC taps the raw pre-estimation SRS; AIR taps after channel estimation.
Two services
| Service | Style | Purpose |
|---|---|---|
| REPORT | 1 — SRS UE Estimate | Per-UE uplink SNR / Timing Advance / channel estimate, on SRS-estimate arrival |
| REPORT | 2 — UE Radio Quality | Per-UE downlink RSRP / SS-SINR / MCS / BLER and uplink SNR / MCS / BLER, on a reporting period |
| QUERY | 1 — UE PHY Configuration | One-shot per-UE CellGroupConfig pull (the source of per-UE SRS-Config and UE discovery) |
Each style pairs with the Event Trigger of the same number: Style 1
fires per SRS occasion and takes no period; Style 2 takes a
report_period_ms and takes no other trigger. A builder is created for
one style and accepts only that style's measurement types.
REPORT — subscribing for SRS estimates
Build an AIR subscription by selecting one or more measurement types. With no UE filter the scope is all SRS-sounding UEs; set an individual filter to scope to one UE.
The four measurement types are SRS_WIDEBAND_SNR, SRS_PER_RB_SNR,
TIMING_ADVANCE, and CHANNEL_ESTIMATE_FREQ.
from airpuls_ric_sdk import (
AirSubscriptionBuilder, AirMeasType, air_plugin_get, AIR_RAN_FUNC_ID,
)
class SrsEstimates(BaseXApp):
def plugins(self):
return [air_plugin_get()]
def on_e2_node_available(self, client, node):
if node.find_ran_function(AIR_RAN_FUNC_ID) is None:
return
b = AirSubscriptionBuilder(AirStyle.SRS_UE_ESTIMATE)
b.add_measurement(AirMeasType.SRS_WIDEBAND_SNR)
b.add_measurement(AirMeasType.SRS_PER_RB_SNR)
b.add_measurement(AirMeasType.TIMING_ADVANCE)
b.add_measurement(AirMeasType.CHANNEL_ESTIMATE_FREQ)
# (no UE filter → scope = every SRS-sounding UE)
client.subscribe_air(node, b)
#include "e2sm-air.h"
air_subscription_builder_t *b = air_subscription_builder_new(AIR_STYLE_1);
air_sub_add_measurement(b, AIR_MEAS_SRS_WIDEBAND_SNR);
air_sub_add_measurement(b, AIR_MEAS_SRS_PER_RB_SNR);
air_sub_add_measurement(b, AIR_MEAS_TIMING_ADVANCE);
air_sub_add_measurement(b, AIR_MEAS_CHANNEL_ESTIMATE_FREQ);
uint32_t sub_id = 0;
ric_client_subscribe_air(client, node, b, &sub_id); /* consumes b */
Reporting conditions — report only when it matters
An SRS-sounding UE produces an occasion on every SRS period. A reporting condition suppresses the indication unless the occasion is interesting, which is how a sensing xApp watches a whole cell without drowning in reports.
Conditions test a scalar the agent already computed, so they cost nothing on the RAN side:
| Condition | Constant | Style | Tests |
|---|---|---|---|
| Wideband SNR | AirTestCondType.SRS_WIDEBAND_SNR |
1 | AIR.srsWidebandSNR, in dB |
| Timing Advance | AirTestCondType.TIMING_ADVANCE_TA_COMMAND |
1 | AIR.timingAdvance.taCommand |
| Downlink RSRP | AirTestCondType.DL_RSRP |
2 | AIR.dlRSRP, in dBm |
| Downlink SINR | AirTestCondType.DL_SINR |
2 | AIR.dlSINR, in tenths of a dB |
| Downlink MCS | AirTestCondType.DL_MCS_INDEX |
2 | AIR.dlMCS.mcsIndex |
| Downlink BLER | AirTestCondType.DL_BLER |
2 | AIR.dlBLER, in parts per million |
| Uplink SNR | AirTestCondType.UL_PUSCH_SNR |
2 | AIR.ulPuschSNR, in tenths of a dB |
| Uplink PUCCH SNR | AirTestCondType.UL_PUCCH_SNR |
2 | AIR.ulPucchSNR, in tenths of a dB |
| Uplink MCS | AirTestCondType.UL_MCS_INDEX |
2 | AIR.ulMCS.mcsIndex |
| Uplink BLER | AirTestCondType.UL_BLER |
2 | AIR.ulBLER, in parts per million |
A condition may only test a scalar of its subscription's own style. A condition on a measurement the occasion did not carry is false — it never passes on an absent value.
Comparisons are EQUAL, GREATERTHAN, LESSTHAN
(AirTestExpr.* / ric.AirTestExpr* / AIR_TEST_EXPR_*).
from airpuls_ric_sdk import (
AirSubscriptionBuilder, AirMeasType, AirTestCondType, AirTestExpr,
)
b = AirSubscriptionBuilder(AirStyle.SRS_UE_ESTIMATE)
b.add_measurement(AirMeasType.SRS_WIDEBAND_SNR)
b.add_measurement(AirMeasType.TIMING_ADVANCE)
# Report only degraded UEs: SNR < 10 dB.
b.add_reporting_condition(
AirTestCondType.SRS_WIDEBAND_SNR, AirTestExpr.LESSTHAN, 10,
logical_or=False,
)
sub_id = client.subscribe_air(node, b)
air_subscription_builder_t *b = air_subscription_builder_new(AIR_STYLE_1);
air_sub_add_measurement(b, AIR_MEAS_SRS_WIDEBAND_SNR);
air_sub_add_measurement(b, AIR_MEAS_TIMING_ADVANCE);
air_sub_add_reporting_condition(b, AIR_TEST_COND_SRS_WIDEBAND_SNR,
AIR_TEST_EXPR_LESSTHAN, 10,
/*logical_or=*/false);
Combining conditions. Up to 8 conditions may be added. The
logical_or flag is sibling glue: false ANDs this condition with the
previous one, true ORs it. The flag on the first condition is ignored,
so a chain reads left to right:
# SNR < 10 dB AND TA > 500 — a distant UE with a poor link
b.add_reporting_condition(AirTestCondType.SRS_WIDEBAND_SNR,
AirTestExpr.LESSTHAN, 10, logical_or=False)
b.add_reporting_condition(AirTestCondType.TIMING_ADVANCE_TA_COMMAND,
AirTestExpr.GREATERTHAN, 500, logical_or=False)
# SNR < 10 dB OR TA > 500 — either symptom alone
b.add_reporting_condition(AirTestCondType.TIMING_ADVANCE_TA_COMMAND,
AirTestExpr.GREATERTHAN, 500, logical_or=True)
With no conditions the scope is every SRS occasion of every sounding UE — correct for a full-cell sensing sweep, and the reason a subscription without conditions can be a firehose at high SRS periodicity.
Scoping to one UE
Orthogonal to conditions: set_ue_filter(plmn, nr_cell_id, c_rnti)
narrows the subscription to a single UE, and clear_ue_filter() returns
it to all-UEs. Filtering picks which UEs; conditions pick which
occasions.
The UE is named by the identity every AIR style reports — its C-RNTI and the cell it was assigned in. The cell is required: a C-RNTI repeats across the cells of a multi-cell node, so an unqualified one selects more UEs than you named. If you hold a standard UEID instead, resolve it with QUERY Style 1, whose outcome carries both halves per UE.
Subscribing for radio quality (Style 2)
Style 2 reports on a period rather than per occasion, and every measurement is downlink:
from airpuls_ric_sdk import AirSubscriptionBuilder, AirStyle, AirMeasType
b = AirSubscriptionBuilder(AirStyle.UE_RADIO_QUALITY)
b.add_measurement(AirMeasType.DL_RSRP)
b.add_measurement(AirMeasType.DL_MCS)
b.add_measurement(AirMeasType.DL_BLER)
b.set_report_period(100) # ms — mandatory for Style 2
client.subscribe_air(node, b)
Each indication carries one block per UE in scope. A node advertises Style 2 only if it can serve it, so check the RAN function's report styles before subscribing.
Reading an SRS estimate
An AIR REPORT indication carries two things:
- Per-UE scalar fields — through the generic measurement blocks
(like KPM: iterate
data.blocks). Style 1 carries one block per indication, Style 2 one block per UE in scope. - The channel estimate — through a typed accessor, not a measurement.
The values in a block
These are the exact strings m.name carries. Every value is an integer
(m.type == INTEGER) except AIR.cellGlobalID.plmn, which is an octet
string; which of the optional ones are present depends on the
measurement types you subscribed to.
What kind of quantity each one is
A record mixes four kinds of thing, and the difference decides how you may read a value. A control-loop state does not track the air interface: it tracks whatever its loop is steering towards, so it sits near its target and moves only when the loop is disturbed. Reading one as a channel measurement is the mistake this table exists to prevent.
| Kind | Measurements | How to read it |
|---|---|---|
| UE-reported measurement | AIR.dlRSRP, AIR.dlSINR |
What the UE measured and sent back. Tracks the downlink. |
| Node-side measurement | AIR.srsWidebandSNR, AIR.srsPerRbSNR.RB<n>, AIR.timingAdvance.* |
What the node estimated from the UE's sounding. Tracks the uplink. |
| Control-loop state | AIR.ulPuschSNR, AIR.ulPucchSNR, and their …TargetDelta |
The uplink power-control loop's own filtered estimate, not a measurement of the channel. Expect it near target, with excursions when the loop is disturbed — a handover, for instance. |
| Scheduler decision | AIR.dlMCS.*, AIR.ulMCS.* |
What link adaptation chose. An excellent proxy for channel quality, because it reacts to it, but it is an output, not an observation. |
| Observed outcome | AIR.dlBLER, AIR.ulBLER |
What actually happened on the link over the node's own window. |
Saturation
Three of these can sit at a limit and look like a flat, healthy signal:
AIR.dlRSRPsaturates at −44 dBm. The UE-side encoder clamps anything above −44 to reporting index 113, 13 dB below where its own linear mapping would run out. A UE close to the cell reports −44 indefinitely. This is an implementation cap, not a limit of the reporting range, and it reproduces on hardware.AIR.srsWidebandSNRandAIR.srsPerRbSNR.RB<n>degenerate when the noise estimate underflows. Both are computed as a difference of two dB terms with a clamped noise floor; if the noise term clamps, the expression reduces to signal power and stops being a ratio at all. A per-RB array can hold both kinds at once, so no summary over it is meaningful — use the distribution.
m.name |
Present when | Meaning |
|---|---|---|
AIR.cRNTI |
always | C-RNTI of the UE — with the cell below, the identity, see the note |
AIR.cellGlobalID.nrCellIdentity |
always | 36-bit NR Cell Identity the C-RNTI was assigned in |
AIR.cellGlobalID.plmn |
always | 3-byte PLMN Identity of that cell (an octet string, not an integer) |
AIR.nRxAntennas |
Style 1 | Receive antennas the estimate spans |
AIR.nSrsPorts |
Style 1 | SRS ports per antenna |
AIR.srsWidebandSNR |
SRS_WIDEBAND_SNR requested |
Wideband SNR in dB |
AIR.srsPerRbSNR.RB<n> |
SRS_PER_RB_SNR requested |
Per-RB SNR in dB, one measurement per resource block — AIR.srsPerRbSNR.RB0, AIR.srsPerRbSNR.RB1, … |
AIR.timingAdvance.taCommand |
TIMING_ADVANCE requested |
SRS-derived Timing Advance command |
AIR.timingAdvance.taOffsetNsec.Ant<n> |
TIMING_ADVANCE requested, per antenna |
Per-antenna TA offset in nanoseconds |
AIR.dlRSRP |
DL_RSRP requested, and the UE has reported one |
Downlink SS-RSRP in dBm, −157…−31 (TS 38.215 §5.1.1; the reported quantity of every bin of TS 38.133 Table 10.1.6.1-1) |
AIR.dlSINR |
DL_SINR requested, and the UE has reported one |
Downlink SS-SINR in tenths of a dB, −235…400 (TS 38.215 §5.1.5; likewise over TS 38.133 Table 10.1.16.1-1, in half-dB steps) |
AIR.dlMCS.mcsIndex |
DL_MCS requested, and the UE has been served |
Downlink MCS index (TS 38.214 §5.1.3.1) |
AIR.dlMCS.mcsTable |
with AIR.dlMCS.mcsIndex |
Which MCS table that index refers to: 0 qam64, 1 qam256, 2 qam64LowSE |
AIR.dlBLER |
DL_BLER requested, and the UE has been served |
Downlink block error ratio in parts per million (1 000 = 0.001) |
AIR.ulPuschSNR |
UL_PUSCH_SNR requested, and the UE has transmitted |
Uplink PUSCH SNR in tenths of a dB, −300…600. The node's own receive-side estimate, not something the UE reports, which is why its range is wider than AIR.dlSINR's |
AIR.ulPuschSNRTargetDelta |
with AIR.ulPuschSNR |
How far AIR.ulPuschSNR sits from the power-control target for PUSCH, in tenths of a dB. A converged loop and an unconverged one at the same SNR mean different things |
AIR.ulPucchSNR |
UL_PUCCH_SNR requested, and the UE has been served |
Uplink PUCCH SNR in tenths of a dB, −300…600. PUCCH carries the downlink feedback, so it exists once the UE has been served |
AIR.ulPucchSNRTargetDelta |
with AIR.ulPucchSNR |
How far AIR.ulPucchSNR sits from the power-control target for PUCCH, in tenths of a dB. Paired with its SNR for the same reason as the PUSCH one: a converged loop and an unconverged one at the same SNR mean different things |
AIR.ulMCS.mcsIndex |
UL_MCS requested, and the UE has transmitted |
Uplink MCS index (TS 38.214 §6.1.4.1) |
AIR.ulMCS.mcsTable |
with AIR.ulMCS.mcsIndex |
Which MCS table that index refers to: 0 qam64, 1 qam256, 2 qam64LowSE |
AIR.ulBLER |
UL_BLER requested, and the UE has transmitted |
Uplink block error ratio in parts per million (1 000 = 0.001) |
A Style 2 indication carries one block per UE in scope, not one
block per indication — iterate data.blocks. A measurement the node
does not hold for a UE is absent from the block, never zero: a UE
that has reported no CSI yet has no AIR.dlRSRP, and one the scheduler has
not served yet has no dlMCS or AIR.dlBLER.
AIR.srsPerRbSNR.RB<n> and AIR.timingAdvance.taOffsetNsec.Ant<n> are indexed families —
the count is whatever the occasion carried, so iterate the block rather
than indexing a fixed set of names:
Key on (cellGlobalID, cRNTI)
AIR identifies a UE by its C-RNTI within a cell. A C-RNTI is unique in a cell, not in a node, so both halves are part of the key on a multi-cell base station.
Key on the measurements, not on block.ue_id. Every block of
every AIR style carries AIR.cRNTI, AIR.cellGlobalID.nrCellIdentity and
AIR.cellGlobalID.plmn as measurements, unconditionally — so
(plmn, nr_cell_id, c_rnti) is always readable and needs no
branching. Note AIR.cellGlobalID.plmn is an octet string; every
other AIR measurement is an integer, so a consumer that coerces
everything to a number will drop it.
block.ue_id is a different thing: the standard E2SM identifier,
carried only when the node could resolve one. Style 1 is taken
below RRC — the SRS tap sees a C-RNTI and nothing above it — so its
blocks report ue_id.kind == CELL_RNTI. Style 2 and QUERY Style 1
read per-UE state alongside the node's UE context, so their blocks
carry the real AMF-UE-NGAP-ID or gNB-CU-UE-F1AP-ID instead —
whenever that context has resolved the UE, which it has not for a UE
still attaching or on a node with no RRC.
ue_id holds one identity, not both
sm_ue_id_t is a tagged union, so when the standard identifier
is present it replaces the cell-RNTI arm rather than
accompanying it. SmUeId.c_rnti / .nr_cell_id / .plmn
therefore return None on exactly the blocks where the node
knows the most about the UE. Use them only as a convenience
for kind == CELL_RNTI; the measurements above are the reliable
key for any per-UE series.
Nothing is ever substituted: an absent standard identifier means the node genuinely has none.
QUERY Style 1 is how you bridge the two — its outcome carries both
halves per UE, so one query maps a real UEID onto the
(cellGlobalID, cRNTI) your Style 1 stream is keyed on.
C-RNTIs are reassigned after a UE detaches, so the key is not stable across detach.
The channel estimate
Requested with CHANNEL_ESTIMATE_FREQ, and read through
air_channel_estimate() / ChannelEstimate() rather than the blocks: it
is raw frequency-domain IQ per antenna / port, not a scalar. None /
nil when it was not requested or the occasion carried none.
CHANNEL_ESTIMATE_FREQ makes indications large
A channel estimate is n_symbols x fft_size x 4 bytes per
antenna/port row — ~256 KB at 100 MHz. The RIC drops any indication
that exceeds transport.southbound.recv_bufsize (default 1 MB, which
covers an 8×8 occasion at one symbol — see
RIC configuration) and logs
Dropping oversized SCTP message. At higher symbol counts, raise
that knob — otherwise the estimates that carry a channel estimate go
missing while the scalar-only ones still arrive, which reads as a
halved report rate rather than an error.
The sending side has a matching constraint: SCTP refuses a message
larger than the socket send buffer, so the RAN host needs
net.core.wmem_max raised (or the agent granted CAP_NET_ADMIN) for
the largest geometries.
def on_indication(self, client, sub_id, node, ran_func_id, header, data):
# 1) scalar fields via the generic blocks
for block in data.blocks:
for m in block.measurements:
print(block.ue_id.display, m.name, m.value)
# 2) the typed channel estimate (None if not requested/present)
est = data.air_channel_estimate()
if est is not None:
for a in range(est.antenna_count):
for p in range(est.port_count(a)):
iq = est.iq_raw(a, p) # raw big-endian int16 I/Q
# decode to magnitude_db / phase_rad as your DSP needs
func (h *handler) OnIndication(c *ric.Client, subID uint32, node *ric.E2Node,
ranFuncID uint16, hdr *ric.IndicationHeader, data *ric.IndicationData) {
// 1) scalar fields via the generic blocks
for _, block := range data.Blocks() {
for _, m := range block.Measurements() {
log.Printf("%s = %d", m.Name(), m.Integer())
}
}
// 2) the typed channel estimate (nil if not requested/present)
est := data.ChannelEstimate()
if est == nil {
return
}
for a := 0; a < est.AntennaCount(); a++ {
for p := 0; p < est.PortCount(a); p++ {
iq := est.IQRaw(a, p) // raw big-endian int16 I/Q
_ = iq
}
}
}
/* 1) scalar fields via the generic measurement blocks — see the KPM guide. */
/* 2) the typed channel estimate */
const air_channel_estimate_t *est = air_indication_channel_estimate(data);
if (est) {
for (size_t a = 0; a < air_channel_estimate_antenna_count(est); a++) {
for (size_t p = 0; p < air_channel_estimate_port_count(est, a); p++) {
size_t len = 0;
const uint8_t *iq = air_channel_estimate_iq_raw(est, a, p, &len);
/* raw big-endian int16 I/Q pairs */
}
}
}
The IQ is raw — you own the DSP
iq_raw(antenna, port) returns the frequency-domain channel as raw
big-endian int16 I/Q pairs. Decoding it to magnitude/phase, and
any downstream sensing (AoA, micro-Doppler, ToA), is the xApp's job —
see xApps → Sensing.
QUERY — pulling per-UE PHY config
AIR QUERY Style 1 pulls each UE's CellGroupConfig (3GPP TS 38.331) —
the authoritative source of the per-UE SRS-Config and the set of active
UEs. It rides the RIC Query procedure, not a subscription. A sensing
xApp runs it on a cadence to keep its UE catalogue and SRS geometry
current, then joins the AIR/LLC indication streams to it on
(plmn, nr_cell_id, c_rnti).
from airpuls_ric_sdk import AirQueryBuilder
qb = AirQueryBuilder() # empty builder = every UE
# qb.set_ue_filter(plmn, nr_cell_id, c_rnti) # or narrow to one UE
ues = client.query_air(node, qb) # one-shot; consumes qb
for ue in ues:
# {"c_rnti": int, "nr_cell_id": int, "plmn": bytes,
# "kind": int | None, "ue_id_display": str | None,
# "cell_group_config": bytes}
print(ue["c_rnti"], ue["nr_cell_id"], len(ue["cell_group_config"]))
air_query_builder_t *qb = air_query_builder_new();
air_query_outcome_t *out = NULL;
ric_client_query_air(client, node, qb, &out, /*timeout_ms=*/5000);
/* iterate with air_query_outcome_ue_count /
* _ue_identity_at (then air_ue_identity_c_rnti / _nr_cell_id /
* _plmn / _ue_id) / _cell_group_config_at;
* free with air_query_outcome_free(out) */
The cell_group_config is the UE's verbatim UPER-encoded
TS 38.331 CellGroupConfig. The SDK does not decode it — an ISAC xApp
that needs the SRS resource layout decodes it with its own ASN.1 tooling.
(cell, cRNTI) is the correlation key
Correlate AIR REPORT indications, LLC SRS/CSI copies, and QUERY
snapshots on (nr_cell_id, c_rnti) — the identity every AIR path
reports. The query outcome is where you learn a UE's standard
identifier alongside it, which REPORT Style 1 does not carry at
all.
Reference
| Concept | Python | Go | C |
|---|---|---|---|
| Plugin | air_plugin_get() |
ric.AirPluginGet() |
air_plugin_get() |
| RAN Function ID | AIR_RAN_FUNC_ID (101) |
ric.AirRanFuncID |
AIR_RAN_FUNC_ID |
| OID | AIR_OID |
ric.AirOID |
AIR_OID |
| REPORT builder | AirSubscriptionBuilder(AirStyle.…) |
ric.NewAirSubscriptionBuilder(ric.AirStyle…) |
air_subscription_builder_new(AIR_STYLE_…) |
| Measurement | add_measurement(AirMeasType.…) |
AddMeasurement(ric.AirMeas…) |
air_sub_add_measurement(b, AIR_MEAS_…) |
| Reporting period (Style 2) | set_report_period(ms) |
SetReportPeriod(ms) |
air_sub_set_report_period(b, ms) |
| UE filter | set_ue_filter(plmn, cell, rnti) / clear_ue_filter() |
SetUEFilter(…) / ClearUEFilter() |
air_sub_set_ue_filter(…) |
| Reporting condition | add_reporting_condition(type, expr, value, logical_or) |
AddReportingCondition(…) |
air_sub_add_reporting_condition(…) |
| Subscribe | client.subscribe_air(node, b) |
client.SubscribeAIR(node, b) |
ric_client_subscribe_air(...) |
| Channel estimate | data.air_channel_estimate() |
data.ChannelEstimate() |
air_indication_channel_estimate(data) |
| Query builder | AirQueryBuilder() |
ric.NewAirQueryBuilder() |
air_query_builder_new() |
| Query UE filter | set_ue_filter(plmn, cell, rnti) |
SetUEFilter(…) |
air_query_set_ue_filter(…) |
| Query | client.query_air(node, qb) |
client.QueryAIR(node, qb, ms) |
ric_client_query_air(...) |
Bundled examples
srs-estimates (Python) is the comprehensive AIR collector feeding a
dashboard; ran-sensing (Python) fuses AIR with LLC and CCC into a full
ISAC pipeline. See xApps → Sensing.