Skip to content

E2SM-RC — RAN Control

E2SM-RC is the radio control axis: per-UE observation and decisions. It is the richest SM in airpuls — it spans four RIC services: REPORT (observe RRC events, cell info, UE state), INSERT (intercept a gNB decision and rule on it), CONTROL (issue a decision such as a handover), and QUERY (snapshot known UEs on demand).

Spec anchors

  • O-RAN.WG3.E2SM-RC-v01.03 (QUERY on R004-v09.00).
  • OID 1.3.6.1.4.1.53148.1.1.2.3, default RAN Function ID 3.

What RC gives you

Service Implemented Use
REPORT Styles 1–4 Observe RRC message copies, call-process outcomes, cell info, UE RRC-state transitions
INSERT Style 3 / Action 1 (Handover) Intercept a gNB-initiated handover before it fires; drop / accept / manipulate it
CONTROL Style 3 / Action 1 (Handover) Issue a handover — standalone, or as the INSERT response
QUERY Style 1 (E2 Node Information), Style 2 (UE Information) One-shot snapshot of served cells / known UEs, over the RIC Query procedure

REPORT — observing the RAN

An RC REPORT subscription selects a style and the RAN parameters to report. Style 1 copies RRC messages (with the UE ID); Style 4 reports UE RRC-state transitions; Style 3 reports per-cell config + neighbour relations.

from airpuls_ric_sdk import (
    RcSubscriptionBuilder, RcStyle,
    RcNrRrcClass, RcNrUlDcchMsg, RcNrDlDcchMsg,
    RC_RANPARAM_RRC_MESSAGE, RC_RANPARAM_UE_ID,
    RC_RANPARAM_RRC_STATE_CHANGED_TO,
)

# Style 1 — Message Copy (cell-level RRC message + UE ID).
# Name the messages to copy — one event per message, by NR RRC
# class + zero-based Message ID; the i-th event gets Event
# Trigger Condition ID i+1, echoed in each indication header.
b = RcSubscriptionBuilder(RcStyle.STYLE_1)
b.add_ran_param(RC_RANPARAM_RRC_MESSAGE)
b.add_ran_param(RC_RANPARAM_UE_ID)
b.add_rrc_message_event(RcNrRrcClass.UL_DCCH,
                        RcNrUlDcchMsg.MEASUREMENT_REPORT)
b.add_rrc_message_event(RcNrRrcClass.DL_DCCH,
                        RcNrDlDcchMsg.RRC_RECONFIGURATION)
# Network-interface copies use the interface's own Elementary
# Procedure Code (E2SM §6.2.2.12) plus the request/outcome
# discriminator — a procedure's failure answer is UNSUCCESSFUL.
b.add_ni_message_event(RcNiType.F1, RcF1Procedure.UE_CONTEXT_SETUP,
                       RcNiMsgType.INITIATING)
b.add_ni_message_event(RcNiType.F1, RcF1Procedure.UE_CONTEXT_SETUP,
                       RcNiMsgType.SUCCESSFUL)
sub_id = client.subscribe_rc(node, b)

# Style 4 — UE Information (RRC state transitions)
b = RcSubscriptionBuilder(RcStyle.STYLE_4)
b.add_ran_param(RC_RANPARAM_RRC_STATE_CHANGED_TO)
sub_id = client.subscribe_rc(node, b)
// Style 1 — Message Copy: one event per message to copy, by NR
// RRC class + zero-based Message ID; the i-th event gets Event
// Trigger Condition ID i+1, echoed in each indication header.
b := ric.NewRcSubscriptionBuilder(ric.RcStyle1)
b.AddRanParam(ric.RcRanParamRRCMessage).
    AddRanParam(ric.RcRanParamUeID).
    AddRrcMessageEvent(ric.RcNrRrcClassUlDcch, ric.RcNrUlDcchMeasurementReport).
    AddRrcMessageEvent(ric.RcNrRrcClassDlDcch, ric.RcNrDlDcchRrcReconfiguration).
    AddNiMessageEvent(ric.RcNiTypeF1, ric.RcF1ProcUeContextSetup, ric.RcNiMsgInitiating)
subID, err := client.SubscribeRC(node, b)

// Style 4 — UE RRC state transitions
b = ric.NewRcSubscriptionBuilder(ric.RcStyle4)
b.AddRanParam(ric.RcRanParamRRCStateChangedTo)
subID, err = client.SubscribeRC(node, b)
#include "e2sm-rc.h"

rc_subscription_builder_t *b = rc_subscription_builder_new(RC_STYLE_1);
rc_sub_add_ran_param(b, /* RAN parameter ID */ );
/* Name the messages to copy — one event per message, by NR RRC
 * class + zero-based Message ID.  The i-th event gets Event
 * Trigger Condition ID i+1, echoed in each indication header. */
rc_sub_add_rrc_message_event(b, RC_NR_RRC_CLASS_UL_DCCH,
                             RC_NR_UL_DCCH_MEASUREMENT_REPORT);
uint32_t sub_id = 0;
ric_client_subscribe_rc(client, node, b, &sub_id);   /* consumes b */

Indications arrive on on_indication, and RANParameter values surface as the generic measurement blocks (see Building xApps).

The values in a block

Unlike KPM, RC names are fixed — a RANParameter ID maps to a name the decoder assigns, so these are the exact strings m.name carries:

m.name Requested with Value type Meaning
NI_Message RC_RANPARAM_NI_MESSAGE (2) octet string The copied network-interface PDU, verbatim (an F1AP PDU today) — decode with pycrate's F1AP module; NI copies never carry a UE_ID, so extract the gNB-CU/DU UE F1AP IDs from the decoded PDU
RRC_Message RC_RANPARAM_RRC_MESSAGE (3) octet string The copied RRC PDU, verbatim — decode with airpuls_ric_sdk.rrc.decode_message(octets, <RrcClass>) (needs the rrc extra: pip install airpuls-ric-sdk[rrc]); the class to pass is the one of the condition that fired, which the header's condition ID identifies
UE_ID RC_RANPARAM_UE_ID (4) octet string APER-encoded UEID of the UE the copied message belongs to (E2SM-RC R004 v09.00 Clause 9.3.10) — decode with airpuls_ric_sdk.decode_ue_id_octets(octets); absent when the node has not resolved the UE
RRC_State_Changed_To RC_RANPARAM_RRC_STATE_CHANGED_TO (202) integer The RRC state the UE entered — an RcRrcState wire value (Clause 9.3.37): 0 connected, 1 inactive, 2 idle
cellDeleted Style 3, automatic integer (boolean) Set when the reported cell was removed
cellContextInfo Style 3, automatic integer Byte length of the cell's context blob

A RANParameter you request that the node does not advertise never appears as a measurement. Instead the RIC raises a MEASUREMENT_NOT_ADVERTISED notification naming it RANParameter-ID <n> — handle it in on_notification rather than waiting for a value that will not come.

RRC_Message is bytes, not a number

RC RANParameter values span every primitive branch of RANParameter-Value — boolean, integer, real, bit string, octet string, printable string. A KPM-shaped loop that calls m.integer unconditionally reads 0 for RRC_Message and looks like a decode bug. Dispatch on m.type, or use m.value, which does it for you:

for m in block.measurements:
    v = m.value          # int | float | bool | bytes | str | None
    if m.name == "RRC_Message":
        handle_rrc_pdu(v)        # bytes

Which block is which

RC uses three Indication Message Formats, and the block shape differs:

Format Used by Blocks block.ue_id.display
1 Style 1 (Message Copy) one cell-level block holding the RANParameters cell
2 Style 4 (UE Information) one block per UE — ue_id.gnb_cu_ue_f1ap_id carries the F1AP handover key when present ue:amf_ue_ngap_id:<n>
3 Style 3 (E2 Node Information) one block per served cell nr:<plmn>/0x<nci> or eutra:<plmn>/0x<nci>

So a Style 1 subscription that also asked for UE_ID reports the UE as a measurement, not as the block's identity — the block itself is cell-scoped.

Narrowing what you get — INSERT filters

RC has no REPORT-side test conditions: a REPORT subscription reports every event of the styles you asked for. Narrowing happens on the INSERT side, where five filters pin the subscription to the handovers you care about. All are optional; setting none intercepts every handover.

Filter Matches
set_target_cell_filter(cell_id) only handovers into this cell
set_source_cell_filter(cell_id) only handovers out of this cell
set_ho_type_filter(ho_type) only this handover type
set_neighbour_pci_filter(pci) only this neighbour PCI
set_rrc_ue_id_filter(rrc_ue_id) only this UE
b = RcSubscriptionBuilder.new_insert()
b.set_target_cell_filter(target_cell_id)   # gate only inbound HOs to one cell
b.set_source_cell_filter(source_cell_id)   # …from one specific source
client.subscribe_rc(node, b)

Filters combine as AND — every one you set must match. Go uses the same names Go-cased (SetTargetCellFilter, …); C uses rc_sub_set_*_filter.

To narrow a REPORT stream, filter in the xApp on the values above, or subscribe per-UE with QUERY to learn the UE set first.

CONTROL — issuing a handover

An RC CONTROL Style 3 / Action 1 hands a UE over to a target cell. Identify the UE (a gNB UE ID with its GUAMI) and the target NR cell.

The identity must be echoed from the node's own indication, never constructed: an AMF UE NGAP ID is unique only within its AMF, so a node refuses a GUAMI it does not serve. hdr below is a header decoded from an earlier indication (RcInsertHeader, or REPORT format 2).

from airpuls_ric_sdk import RcControlBuilder

b = RcControlBuilder(style_type=3, action_id=1)
b.set_ue_id_gnb(
    amf_ue_ngap_id=hdr.ue_id_amf_ue_ngap_id,
    plmn=hdr.guami.plmn,
    amf_region_id=hdr.guami.amf_region_id,
    amf_set_id=hdr.guami.amf_set_id,
    amf_pointer=hdr.guami.amf_pointer,
)
b.set_target_cell_nr(
    plmn=b"\x00\xf1\x10",
    nr_cell_id=b"\x00\x00\x00\x01\x00",
)
outcome = client.control_rc(node, b, timeout_ms=5000)
ctrl := ric.NewRcControlBuilder(3, 1)
ctrl.SetUeIDGnb(hdr.UeIDAmfUeNgapID, hdr.Guami.Plmn,
    hdr.Guami.AmfRegionID, hdr.Guami.AmfSetID, hdr.Guami.AmfPointer).
    SetTargetCellNR(plmn, nrCellID)
outcome, err := client.ControlRC(node, ctrl, 5000)
rc_control_builder_t *cb = rc_control_builder_new(/*style_type=*/3, /*action_id=*/1);
rc_ctrl_set_ue_id_gnb(cb, hdr->ue_id_amf_ue_ngap_id, hdr->ue_id_guami_plmn,
                      hdr->ue_id_guami_amf_region_id,
                      hdr->ue_id_guami_amf_set_id,
                      hdr->ue_id_guami_amf_pointer);
rc_ctrl_set_target_cell_nr(cb, plmn, /*nr_cell_id[5]=*/nci);
sm_indication_data_t *outcome = NULL;
ric_client_control_rc(client, node, cb, &outcome, /*timeout_ms=*/5000);
/* outcome may be NULL — that is success, see the note below */

The Control Outcome is optional in E2AP — a missing outcome is success, not an error. control_rc returns None / nil / leaves *out_outcome NULL when the node acknowledged without one; NULL-check before reading it.

A handover CONTROL needs a target cell

Style 3 / Action 1 without set_target_cell_nr (or set_target_cell_eutra) has nowhere to hand the UE to. Set the UE identity and the target cell on every standalone handover control.

INSERT — gating a gNB-initiated handover

INSERT is the powerful RC pattern: the gNB is about to hand a UE over, the agent pauses it and asks the RIC, and your xApp rules on it. All three verdicts work end-to-end:

  • drop — reply REJECT; the UE stays on the source cell, the gNB's handover never starts.
  • accept — reply ACCEPT; the gNB fires the handover toward the originally-captured target.
  • manipulate — reply ACCEPT with a different target cell; the gNB fires the handover toward the cell you chose.

Subscribe with the INSERT builder (optionally filtered to a target cell), then answer each INSERT indication with a CONTROL that echoes the Call Process ID — that is what distinguishes an INSERT response from a standalone CONTROL.

from airpuls_ric_sdk import (
    RcSubscriptionBuilder, RcControlBuilder,
    decode_rc_insert_header_fmt2, decode_rc_insert_message_fmt5,
    decode_rc_call_process_id_fmt1,
)

RC_CONTROL_STYLE_HO = 3
RC_DECISION_ACCEPT, RC_DECISION_REJECT = 0, 1

class HoGate(BaseXApp):
    def plugins(self):
        return [rc_plugin_get()]

    def on_e2_node_available(self, client, node):
        b = RcSubscriptionBuilder.new_insert()
        b.set_target_cell_filter(self._target_cell_id)   # only HOs into this cell
        client.subscribe_rc(node, b)

    # header/message/cpi arrive as raw bytes — decode, decide, answer.
    def on_insert_indication(self, client, sub_id, node, ran_func_id,
                             header_bytes, message_bytes, cpi_bytes):
        hdr = decode_rc_insert_header_fmt2(header_bytes)
        evt = decode_rc_insert_message_fmt5(message_bytes)
        cpi = decode_rc_call_process_id_fmt1(cpi_bytes)

        # hdr names the UE the way its arm defines it.  Read only the
        # fields the reported kind carries: a gNB header has the AMF
        # UE NGAP ID and a GUAMI, and — on a CU-DU split, where
        # E2SM-COMMON requires it — hdr.gnb_cu_ue_f1ap_id as well; a
        # gNB-DU header has the F1AP ID alone.  hdr.guami must be
        # echoed rather than invented if you name this UE back to the
        # node in a standalone control: an AMF UE NGAP ID is unique
        # only within its AMF, so a node refuses a GUAMI it does not
        # serve.  An INSERT answer is keyed on the CPI instead.

        b = RcControlBuilder(RC_CONTROL_STYLE_HO, action_id=1)
        b.set_call_process_id(cpi_bytes)                 # echo the CPI — mandatory
        if self._redirect_to:                            # manipulate
            b.set_target_cell_nr(self._plmn, self._redirect_to)
            b.set_decision(RC_DECISION_ACCEPT)
        else:                                            # drop
            b.set_decision(RC_DECISION_REJECT)
        client.control_rc(node, b)
// Subscribe for INSERT, pinned to handovers into one target cell.
ib := ric.NewRcInsertSubscriptionBuilder()
ib.SetTargetCellFilter(targetCellID)
subID, err := client.SubscribeRC(node, ib)

// Answer each INSERT: echo the CPI, set the decision, optionally
// redirect, then issue the CONTROL.
func (h *handler) OnInsertIndication(c *ric.Client, subID uint32,
    node *ric.E2Node, ranFuncID uint16,
    headerBytes, messageBytes, cpiBytes []byte) {

    evt, err := ric.DecodeRcInsertMessageFmt5(messageBytes)
    if err != nil {
        return
    }
    cb := ric.NewRcControlBuilder(3, 1)
    cb.SetCallProcessID(cpiBytes)          // echo — mandatory
    if h.redirectTo != nil {               // manipulate
        cb.SetTargetCellNR(h.plmn, *h.redirectTo).SetDecision(0 /*accept*/)
    } else {                               // drop
        cb.SetDecision(1 /*reject*/)
    }
    _, _ = c.ControlRC(node, cb, 5000)
    _ = evt
}
/* Subscribe for INSERT with a target-cell filter. */
rc_subscription_builder_t *ib = rc_subscription_builder_new_insert();
rc_sub_set_target_cell_filter(ib, target_cell_id);
ric_client_subscribe_rc(client, node, ib, &sub_id);

/* In the on_insert_indication callback: echo the CPI, set the decision,
 * optionally set a redirect target, then answer with a CONTROL. */
rc_control_builder_t *cb = rc_control_builder_new(/*style_type=*/3, /*action_id=*/1);
rc_ctrl_set_call_process_id(cb, cpi_buf, cpi_len);   /* echo — mandatory */
rc_ctrl_set_target_cell_nr(cb, plmn, redirect_nci);  /* manipulate only */
rc_ctrl_set_decision(cb, /*0=accept, 1=reject*/ 0);
ric_client_control_rc(client, node, cb, &outcome, /*timeout_ms=*/5000);

All three bindings implement the round-trip; the ho-cell-block xApp exercises it in Python and C.

The Call Process ID is what makes it an INSERT response

A CONTROL that does not echo cpi_bytes is a standalone control — the paused gNB procedure is never released and times out. Echo the buffer verbatim; do not re-encode it.

QUERY — one-shot reads

QUERY rides the RIC Query procedure, not a subscription: you ask, the node answers once. Two styles are implemented.

Style 2 — known UEs

A late-starting xApp can enumerate the UEs that registered before it connected — the spec-sanctioned bootstrap that replaced On-Demand REPORT in v03.00. Each entry carries the UE's UEID plus the RAN parameters you asked for.

from airpuls_ric_sdk import RcQueryBuilder, RC_RANPARAM_RRC_STATE_CHANGED_TO

qb = RcQueryBuilder()                       # style 2 is the default
qb.add_ran_param(RC_RANPARAM_RRC_STATE_CHANGED_TO)

ues = client.query_rc(node, qb)             # one-shot; consumes qb
for ue in ues:
    # {"kind": int, "ue_id": int, "params": {ran_param_id: value|None}}
    print(ue["ue_id"], ue["params"].get(RC_RANPARAM_RRC_STATE_CHANGED_TO))
qb := ric.NewRcQueryBuilder(ric.RcQueryStyleUeInfo)
qb.AddRanParam(ric.RcRanParamRRCStateChangedTo)

ues, err := client.QueryRC(node, qb, 5000)  // consumes qb
for _, ue := range ues {
    log.Printf("ue=%d kind=%d params=%v", ue.UeID, ue.Kind, ue.Params)
}
rc_query_builder_t *qb = rc_query_builder_new(/*style_type=*/2);
rc_query_add_ran_param(qb, RC_RANPARAM_RRC_STATE_CHANGED_TO);

rc_query_outcome_t *out = NULL;
ric_client_query_rc(client, node, qb, &out, /*timeout_ms=*/5000);
/* iterate with rc_query_outcome_ue_count / _ue_id_value_at / _ue_param_*;
 * free with rc_query_outcome_free(out) */

Style 1 — served cells

Style 1 returns the node's served-cell set: each entry carries the cell's CGI (kind + PLMN + NR Cell Identity), the requested RAN parameters, and an optional ServingCellConfigCommon.

from airpuls_ric_sdk import RcQueryBuilder, RC_QUERY_STYLE_CELL_INFO

qb = RcQueryBuilder(RC_QUERY_STYLE_CELL_INFO)
qb.add_ran_param(1)     # the wire requires ≥1 id; see the note below

cells = client.query_rc_cells(node, qb)
for cell in cells:
    print(cell["plmn"].hex(), hex(cell["nci"]),
          "sccc" if cell["serving_cell_config"] else "-")
qb := ric.NewRcQueryBuilder(ric.RcQueryStyleCellInfo)
qb.AddRanParam(1)

cells, err := client.QueryRCCells(node, qb, 5000)
for _, c := range cells {
    log.Printf("plmn=%x nci=%#x sccc=%v", c.Plmn, c.Nci, c.ServingCellConfig != nil)
}
rc_query_builder_t *qb = rc_query_builder_new(/*style_type=*/1);
rc_query_add_ran_param(qb, 1);

rc_query_cell_outcome_t *out = NULL;
ric_client_query_rc_cells(client, node, qb, &out, /*timeout_ms=*/5000);
/* free with rc_query_cell_outcome_free(out) */

Why the placeholder RAN parameter on Style 1

The Query Definition requires at least one RAN parameter id on the wire, but the cell identity and ServingCellConfigCommon ride dedicated IEs rather than the parameter list — so any id works and the per-cell parameter list comes back empty. rc-monitor passes 1 for exactly this reason.

Run a query on a cadence, from on_tick

QUERY is synchronous, and blocking from the dispatch thread is safe — the internal wait pumps the event loop, so indications keep flowing. Set xapp.tick_interval_ms and issue the query from on_tick (Python) to keep a UE/cell catalogue current. A node that does not advertise the style answers Query Failure; log it once per failure streak rather than every interval.

Reference

Concept Python Go C
Plugin rc_plugin_get() ric.RcPluginGet() rc_plugin_get()
RAN Function ID RC_RAN_FUNC_ID (3) ric.RcRanFuncID RC_RAN_FUNC_ID
OID RC_OID ric.RcOID RC_OID
REPORT builder RcSubscriptionBuilder(RcStyle.STYLE_4) ric.NewRcSubscriptionBuilder(ric.RcStyle4) rc_subscription_builder_new(RC_STYLE_4)
RAN parameter add_ran_param(id) AddRanParam(id) rc_sub_add_ran_param(b, id)
INSERT builder RcSubscriptionBuilder.new_insert() ric.NewRcInsertSubscriptionBuilder() rc_subscription_builder_new_insert()
INSERT filters set_target_cell_filter, set_source_cell_filter, set_ho_type_filter, set_neighbour_pci_filter, set_rrc_ue_id_filter same names, Go-cased rc_sub_set_*_filter
Subscribe client.subscribe_rc(node, b) client.SubscribeRC(node, b) ric_client_subscribe_rc(...)
Unsubscribe client.unsubscribe(sub_id) client.Unsubscribe(subID) ric_client_unsubscribe(...)
Control builder RcControlBuilder(3, 1) ric.NewRcControlBuilder(3, 1) rc_control_builder_new(3, 1)
Control client.control_rc(node, b, timeout_ms) client.ControlRC(node, b, ms) ric_client_control_rc(...)
INSERT hook on_insert_indication(...) OnInsertIndication(...) ric_client_set_on_insert_indication(...)
INSERT decoders decode_rc_insert_header_fmt2, decode_rc_insert_message_fmt5, decode_rc_call_process_id_fmt1 ric.DecodeRcInsertHeaderFmt2, ric.DecodeRcInsertMessageFmt5, ric.DecodeRcCallProcessIDFmt1 rc_decode_indication_header_fmt2, rc_decode_indication_message_fmt5, rc_decode_call_process_id_fmt1
Query — UEs client.query_rc(node, qb) client.QueryRC(node, qb, ms) ric_client_query_rc(...)
Query — cells client.query_rc_cells(node, qb) client.QueryRCCells(node, qb, ms) ric_client_query_rc_cells(...)

Bundled examples

rc-monitor (REPORT 1+4 + periodic QUERY), cell-tracker (REPORT 3 cell discovery), ho-trigger (KPM-driven CONTROL handover), and ho-cell-block (INSERT gate). See xApps → Control and Monitoring.