Skip to content

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 ID 101.
  • 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)
b := ric.NewAirSubscriptionBuilder(ric.AirStyle1)
b.AddMeasurement(ric.AirMeasWidebandSNR).
    AddMeasurement(ric.AirMeasPerRbSNR).
    AddMeasurement(ric.AirMeasTimingAdvance).
    AddMeasurement(ric.AirMeasChannelEstimateFreq)
subID, err := client.SubscribeAIR(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)
b := ric.NewAirSubscriptionBuilder(ric.AirStyle1)
b.AddMeasurement(ric.AirMeasWidebandSNR).
    AddMeasurement(ric.AirMeasTimingAdvance).
    AddReportingCondition(ric.AirTestCondWidebandSNR, ric.AirTestExprLessthan, 10, false)
subID, err := client.SubscribeAIR(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)
b := ric.NewAirSubscriptionBuilder(ric.AirStyle2)
b.AddMeasurement(ric.AirMeasDlRSRP).
    AddMeasurement(ric.AirMeasDlMCS).
    AddMeasurement(ric.AirMeasDlBLER).
    SetReportPeriod(100)
subID, err := client.SubscribeAIR(node, b)
air_subscription_builder_t *b = air_subscription_builder_new(AIR_STYLE_2);
air_sub_add_measurement(b, AIR_MEAS_DL_RSRP);
air_sub_add_measurement(b, AIR_MEAS_DL_MCS);
air_sub_add_measurement(b, AIR_MEAS_DL_BLER);
air_sub_set_report_period(b, 100);

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:

  1. 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.
  2. 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.dlRSRP saturates 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.srsWidebandSNR and AIR.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:

per_rb = {m.name: m.value for m in block.measurements
          if m.name.startswith("AIR.srsPerRbSNR.RB")}

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"]))
qb := ric.NewAirQueryBuilder()
ues, err := client.QueryAIR(node, qb, 5000)   // consumes qb
for _, ue := range ues {
    log.Printf("cRNTI=%d cell=%#x cgc=%dB", ue.CRnti, ue.NrCellID,
        len(ue.CellGroupConfig))
}
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.