Skip to content

E2SM-LLC — Lower Layers Control

This page is a self-contained guide to building xApps on top of the E2SM-LLC service model in airpuls. By the end you will understand what LLC is for, how the O-RAN specification structures it, and how to write a monitoring xApp — for both the periodic RLC-buffer-status report and the triggered raw SRS/CSI copy — in Python, Go, or C. A closing section shows how the same xApp consumes live lower-layer data tapped straight from a real gNB.

Specification anchors

  • O-RAN.WG3.TS.E2SM-LLC-R004-v01.00 — the E2 Service Model for Lower Layers Control (the contract implemented here). RAN Function ID 5, OID 1.3.6.1.4.1.53148.1.1.2.5.
  • O-RAN.WG3.TS.E2AP — the E2 Application Protocol that carries the Subscription / Indication procedures LLC rides on.
  • 3GPP TS 38.211 — NR physical channels and signals; the source of the Sounding Reference Signal (SRS) that Style 1 copies raw.
  • 3GPP TS 38.212 — NR multiplexing and channel coding; the source of the Channel State Information (CSI) Part 1 / Part 2 fields.
  • 3GPP TS 38.322 — NR RLC protocol; the buffer the Style 2 dl-rlc-buffer-status snapshot measures.
  • IETF RFC 5905 — the 64-bit NTP timestamp format used for the per-slot slotStartTime.

The 3GPP specs above are not cited verbatim by the implementation: R004 wraps them, and airpuls treats the E2SM-LLC ASN.1 as the contract boundary for what it can emit and decode.


1. What LLC is for

A Near-RT RIC observes and acts on the RAN along three axes, one per service model:

Service model What it gives an xApp Example
E2SM-KPM Measurements — aggregated performance counters DL throughput, PRB usage
E2SM-RC Radio control — per-UE decisions Handover, QoS flow mapping
E2SM-LLC Lower-layers information & control — observe the RAN's lower layers directly DL RLC buffer status, raw SRS / CSI

LLC is the lower-layers axis. Where KPM reports counters aggregated over a window and RC steers individual UEs, LLC exposes the RAN's lower layers with almost no aggregation: the downlink RLC queue depth per logical channel, and the raw PHY bytes of the SRS and CSI a UE just sent up. This is the data a latency-aware scheduler-assist or a beam-management xApp needs — information that lives below the measurement-counter layer and is normally invisible outside the gNB.

Scope of the airpuls implementation

REPORT Style 2 (Periodic, dl-rlc-buffer-status) is implemented end-to-end. REPORT Style 1 (LLI Copy, SRS + CSI) has operational ASN.1 codecs and a full xApp subscription/decode path; its emission is gated at the agent-side constructor and, on the emulator, falls back to synthesised CSI so the xApp path can be exercised everywhere (see §6). CONTROL and INSERT services are not yet implemented — LLC is REPORT-only today.


2. How the specification models LLC

Three ideas, in order.

2.1 Two things LLC observes

E2SM-LLC distinguishes lower-layer information from lower-layer measurements, and airpuls surfaces each as one enum:

  • Lower-Layer Information (llc_lli_type_t) — a copy of raw PHY data as it arrived at the RAN. Two arms: LLC_LLI_SRS (Sounding Reference Signal) and LLC_LLI_CSI (Channel State Information). This is the payload of Style 1.
  • Lower-Layer Measurement (llc_meas_type_t) — a numeric snapshot of a lower-layer state: LLC_MEAS_DL_RLC_BUFFER_STATUS, LLC_MEAS_DL_PDCP_BUFFER_STATUS, LLC_MEAS_DL_HARQ_STATISTICS, LLC_MEAS_SLOT_TIME_STAMP. These are the payload of Style 2; dl-rlc-buffer-status is the one implemented today.

The split matters because the two carry fundamentally different data: measurements are integers grouped per UE and per logical channel; information is opaque PHY byte strings the xApp interprets itself.

Only dl-rlc-buffer-status is wired end-to-end

All four llc_meas_type_t values are accepted by the builder, but the RIC's Indication Message Format 2 decoder only handles the dlRlcBufferStatus arm — LLC_MEAS_DL_PDCP_BUFFER_STATUS, LLC_MEAS_DL_HARQ_STATISTICS, and LLC_MEAS_SLOT_TIME_STAMP are not emitted by the agent nor decoded today, so subscribing for them yields no measurement blocks. Use LLC_MEAS_DL_RLC_BUFFER_STATUS for Style 2.

2.2 The two REPORT styles

Style 2 — Periodic Style 1 — LLI Copy
Trigger every N ms (periodic timer) event-driven: an SRS or CSI arrives at the RAN
Carries DL RLC buffer status (per UE, per LCID) raw SRS and/or CSI, copied verbatim
Use case latency / congestion visibility beam management, channel-quality analytics

Style 2 arms a reporting timer and emits a downlink RLC buffer-status snapshot every period. Style 1 arms one or more event-trigger conditions; when the matching lower-layer information appears (an SRS occasion, a CSI report), the node copies it into an indication and emits it immediately — the indication's header echoes the condition ID that fired. The header carries the single condition ID that matched, not a bitmask: with OR-glued conditions (e.g. SRS on ID 1, CSI on ID 2) each indication echoes the one ID that fired, and because every condition is bound to one llc_lli_type_t, that ID also tells you whether the copy is SRS or CSI before you inspect the LLI.

2.3 Services, styles, and formats

LLC exposes its data through RIC service styles. Each style pins an event-trigger style, an action-definition format, and the indication header/message formats it uses:

REPORT style Event Trigger style Action Def Ind. Header Ind. Message
Style 1 — LLI Copy (triggered, SRS/CSI) 1 1 1 1
Style 2 — Periodic (dl-rlc-buffer-status) 2 2 1 2

Both styles share Indication Header Format 1. The two styles differ in their Indication Message Format: Format 1 carries the typed LLI copy (SRS/CSI); Format 2 carries the per-UE / per-LCID measurement blocks. The SDK decodes Format 1 into a typed llc_lli_t and lowers Format 2 into the same generic measurement-block tree every SM uses, so the xApp branches on which one it received.

No RAN Function Definition decode step

Unlike KPM — whose RAN Function Definition enumerates named counters an xApp must decode before subscribing — LLC Style 2 advertises a single fixed lower-layer measurement set. There is therefore no RAN-function-definition decode step: you check that a node advertises RAN Function 5 (OID 1.3.6.1.4.1.53148.1.1.2.5), then build a subscription by selecting the llc_meas_type_t (Style 2) or llc_lli_type_t (Style 1) directly.

2.4 Every sample is anchored in time — the slot timestamp

A raw SRS or CSI copy is meaningless without knowing which slot it came from, and a slot index is meaningless without its numerology. Every Style 1 indication therefore carries a slot timestamp:

Field Meaning Range / format
systemFramNumber (SFN) System Frame Number 0..1023
slotIndex slot within the frame, as a numerology-tagged CHOICE scs-15 0..9, scs-30 0..19, scs-60 0..39, scs-120 0..79
slotStartTime wall-clock instant the slot started NTP-64, 8 bytes, big-endian (RFC 5905)

The CHOICE arm of slotIndex is the numerology — airpuls surfaces it as llc_scs_t (LLC_SCS_15/30/60/120 = 0/1/2/3). The slotStartTime is a 64-bit NTP timestamp stamped at the moment the data was captured (see §6); it decodes to NTP seconds and fraction per RFC 5905.

Note

The SDK accessors expose all four fields of each Style 1 indication: the SFN, the slot index, the SCS numerology, and the raw 8-byte NTP-64 slotStartTime (llc_lli_slot_start_time in C, slot_start_time_ntp / slot_start_time_s in Python, SlotStartTime / SlotStartTimeSeconds in Go) — see §5.4.


3. The subscription mechanic — what you actually ask for

An LLC subscription is built with one small builder object, sized for the style you pick:

  • Style 2 (Periodic) — set a report period (ms) and add one or more measurement types. The builder emits an Action Definition (Format 2) and a periodic Event Trigger Definition.
  • Style 1 (LLI Copy) — set the LLI type to report and add one or more event-trigger conditions. Each condition pairs a RIC Event Trigger Condition ID (1..65535, echoed back in the indication header) with the llc_lli_type_t whose arrival fires it. Conditions can be OR-glued, so a single subscription can fire on SRS or CSI.

You submit the builder with one call; it is consumed, a subscription ID comes back, and indications then stream to your on_indication handler until you unsubscribe.

sequenceDiagram
    autonumber
    participant X as xApp (Py/Go/C)
    participant S as C SDK (libric-client)
    participant R as Near-RT RIC
    participant N as E2 Node (agent)

    X->>S: subscribe_llc(node, builder)
    Note over X,S: builder = Style 2 (period + meas types)<br/>or Style 1 (LLI type + trigger conditions)
    S->>R: RIC Subscription Request (ranFuncId=5)
    Note over R: routes by ranFuncId — never decodes LLC payload
    R->>N: E2AP RIC Subscription Request
    N->>N: parse Action Def + Event Trigger,<br/>arm timer (Style 2) or trigger (Style 1)
    N-->>R: SubscribeAck
    R-->>S: ack
    S-->>X: sub_id

    alt Style 2 — periodic
        loop every reporting period
            N-->>R: RIC Indication (Format 2: RLC blocks)
            R-->>S: forward
            S-->>X: on_indication(... data.blocks)
        end
    else Style 1 — triggered
        loop on each SRS / CSI arrival
            N-->>R: RIC Indication (Format 1: typed LLI)
            R-->>S: forward
            S-->>X: on_indication(... data.llc_lli())
        end
    end

4. Tutorial — a monitoring xApp

Goal: subscribe to every LLC-capable node for both styles, then log each periodic RLC snapshot and each triggered SRS/CSI copy as it arrives.

Step 1 — register the LLC plugin so the inbound indication path can dispatch LLC indications (keyed by RAN Function ID 5).

Step 2 — on each available node, capability-check for the LLC RAN function, then subscribe twice: a Style 2 periodic RLC subscription, and a Style 1 triggered subscription that fires on SRS or CSI.

Step 3 — in the indication handler, branch on whether a typed LLI is present: if it is, this is a Style 1 (triggered) copy; otherwise it is a Style 2 (periodic) measurement snapshot.

from airpuls_ric_sdk import (
    LLC_RAN_FUNC_ID, llc_plugin_get,
    LlcSubscriptionBuilder, LlcStyle, LlcMeasType, LlcLliType,
    SmMeasurementType,
)
from airpuls_ric_sdk.xapp import BaseXApp, setup_logging

class LlcMonitor(BaseXApp):
    XAPP_TYPE = "llc-monitor"

    # Step 1: advertise the LLC plugin to the SDK.
    def plugins(self):
        return [llc_plugin_get()]

    # Step 2: subscribe to LLC-capable nodes (both styles).
    def on_e2_node_available(self, client, node):
        if node.find_ran_function(LLC_RAN_FUNC_ID) is None:
            return  # node does not advertise LLC

        # Style 2 — periodic DL RLC buffer status, every 1 s.
        periodic = LlcSubscriptionBuilder(LlcStyle.STYLE_2)
        periodic.set_report_period(1000)
        periodic.add_measurement(LlcMeasType.DL_RLC_BUFFER_STATUS)
        client.subscribe_llc(node, periodic)

        # Style 1 — triggered LLI copy, fire on SRS OR CSI.
        trig = LlcSubscriptionBuilder(LlcStyle.STYLE_1)
        trig.set_lli_type(LlcLliType.SRS)
        trig.add_trigger_condition(1, LlcLliType.SRS)
        trig.add_trigger_condition(2, LlcLliType.CSI, logical_or=True)
        client.subscribe_llc(node, trig)

    # Step 3: branch on the indication kind.
    def on_indication(self, client, sub_id, node, ran_func_id, header, data):
        if ran_func_id != LLC_RAN_FUNC_ID:
            return
        lli = data.llc_lli()          # typed LLI for Style 1, else None
        if lli is not None:
            self._on_lli(header, lli)         # triggered SRS/CSI copy
        else:
            self._on_rlc(data)                # periodic RLC blocks

    # --- Style 2: per-UE / per-LCID RLC buffer status ---
    def _on_rlc(self, data):
        for block in data.blocks:
            ue = block.ue_id.display if block.ue_id else "cell"
            for m in block.measurements:
                if m.type == SmMeasurementType.INTEGER:
                    # m.name e.g. "DlRlcBufferOccupancy.LCID0",
                    #             "DlRlcHolTimeToLive.LCID0"
                    print(ue, m.name, m.integer)

    # --- Style 1: typed SRS / CSI copy ---
    def _on_lli(self, header, lli):
        cond = header.event_trigger_condition_id      # which condition fired
        print("LLI", "sfn", lli.slot_sfn, "slot", lli.slot_index,
              "scs", lli.slot_scs, "cond", cond)
        if lli.kind == LlcLliType.SRS:
            for a in range(lli.srs_antenna_count):
                for s in range(lli.srs_symbol_count(a)):
                    raw = lli.srs_raw(a, s)
                    hdr = lli.srs_compression_header(a, s)
                    print("  SRS ant", a, "sym", s,
                          len(raw), "bytes comp_hdr", hdr)
        else:  # LlcLliType.CSI
            for u in range(lli.csi_ue_count):
                for r in range(lli.csi_report_count(u)):
                    cfg = lli.csi_report_config_id(u, r)
                    p1 = lli.csi_part1(u, r)
                    p1_unused = lli.csi_part1_bits_unused(u, r)
                    p2 = lli.csi_part2(u, r)            # bytes or None
                    print("  CSI ue", u, "rep", r, "cfg", cfg,
                          "part1", p1.hex(), "unused", p1_unused,
                          "part2", p2.hex() if p2 else None)

if __name__ == "__main__":
    setup_logging()
    LlcMonitor(config_path="/etc/airpuls/xapp.yml").run_sync()
package main

import (
    "encoding/hex"
    "log"

    "github.com/airpuls/ric-client-go/ric"
)

type handler struct{ ric.DefaultEventHandler }

// Step 2: subscribe to LLC-capable nodes (both styles).  Resolve by
// OID — spec-stable, unlike the node-local RAN Function ID.
func (h *handler) OnE2NodeAvailable(c *ric.Client, node *ric.E2Node) {
    if node.FindRanFunctionByOID(ric.LlcOID) == nil {
        return
    }
    // Style 2 — periodic DL RLC buffer status, every 1 s.
    periodic := ric.NewLlcSubscriptionBuilder(ric.LlcStyle2)
    periodic.SetReportPeriod(1000).
        AddMeasurement(ric.LlcMeasDlRlcBufferStatus)
    c.SubscribeLLC(node, periodic)

    // Style 1 — triggered LLI copy, fire on SRS OR CSI.
    trig := ric.NewLlcSubscriptionBuilder(ric.LlcStyle1)
    trig.SetLliType(ric.LlcLliSrs).
        AddTriggerCondition(1, ric.LlcLliSrs, false).
        AddTriggerCondition(2, ric.LlcLliCsi, true)
    c.SubscribeLLC(node, trig)
}

// Step 3: branch on the indication kind.
func (h *handler) OnIndication(c *ric.Client, subID uint32, node *ric.E2Node,
    ranFuncID uint16, hdr *ric.IndicationHeader, data *ric.IndicationData) {
    if ranFuncID != ric.LlcRanFuncID {
        return
    }
    lli := data.Lli() // typed LLI for Style 1, nil for Style 2
    if lli == nil {
        // Style 2: per-UE / per-LCID RLC buffer status.
        for _, block := range data.Blocks() {
            for _, m := range block.Measurements() {
                // m.Name() e.g. "DlRlcBufferOccupancy.LCID0"
                log.Printf("%s = %d", m.Name(), m.Integer())
            }
        }
        return
    }
    // Style 1: typed SRS / CSI copy.
    log.Printf("LLI sfn=%d slot=%d scs=%d", lli.SlotSfn(),
        lli.SlotIndex(), lli.SlotScs())
    if lli.Kind() == ric.LlcLliSrs {
        for a := 0; a < lli.SrsAntennaCount(); a++ {
            for s := 0; s < lli.SrsSymbolCount(a); s++ {
                raw := lli.SrsRaw(a, s)
                log.Printf("  SRS ant=%d sym=%d %d bytes comp_hdr=%d",
                    a, s, len(raw), lli.SrsCompressionHeader(a, s))
            }
        }
    } else { // ric.LlcLliCsi
        for u := 0; u < lli.CsiUeCount(); u++ {
            for r := 0; r < lli.CsiReportCount(u); r++ {
                p1 := lli.CsiPart1(u, r)
                log.Printf("  CSI ue=%d rep=%d cfg=%d part1=%s unused=%d",
                    u, r, lli.CsiReportConfigID(u, r),
                    hex.EncodeToString(p1), lli.CsiPart1BitsUnused(u, r))
            }
        }
    }
}

func main() {
    cfg := ric.NewXappConfig("/etc/airpuls/xapp.yml")
    defer cfg.Close()
    if err := cfg.Load(); err != nil {
        log.Fatal(err)
    }
    client, err := ric.Connect(cfg)
    if err != nil {
        log.Fatal(err)
    }
    defer client.Close()

    client.RegisterSM(ric.LlcPluginGet()) // Step 1
    client.SetEventHandler(&handler{})
    client.Run()
}
#include <stdio.h>
#include "ric-client.h"
#include "xapp-config.h"
#include "e2sm-llc.h"   /* LLC_RAN_FUNC_ID, llc_plugin_get, builder + LLI accessors */

/* Step 2: subscribe when an LLC-capable node appears. */
static void on_node(ric_client_t *client, const ric_e2_node_t *node, void *ud) {
    (void)ud;
    /* Style 2 — periodic DL RLC buffer status, every 1 s. */
    llc_subscription_builder_t *p = llc_subscription_builder_new(LLC_STYLE_2);
    llc_sub_set_report_period(p, 1000);
    llc_sub_add_measurement(p, LLC_MEAS_DL_RLC_BUFFER_STATUS);
    uint32_t sub_p = 0;
    ric_client_subscribe_llc(client, node, p, &sub_p);   /* builder consumed */

    /* Style 1 — triggered LLI copy, fire on SRS OR CSI. */
    llc_subscription_builder_t *t = llc_subscription_builder_new(LLC_STYLE_1);
    llc_sub_set_lli_type(t, LLC_LLI_SRS);
    llc_sub_add_trigger_condition(t, 1, LLC_LLI_SRS, false);
    llc_sub_add_trigger_condition(t, 2, LLC_LLI_CSI, true);  /* OR-glued */
    uint32_t sub_t = 0;
    ric_client_subscribe_llc(client, node, t, &sub_t);
}

/* Step 3: branch on the indication kind. */
static void on_ind(ric_client_t *client, uint32_t sub_id,
                   const ric_e2_node_t *node, uint16_t ran_func_id,
                   const sm_indication_header_t *hdr,
                   const sm_indication_data_t *data, void *ud) {
    (void)client; (void)sub_id; (void)node; (void)hdr; (void)ud;
    if (ran_func_id != LLC_RAN_FUNC_ID) return;

    const llc_lli_t *lli = llc_indication_lli(data);   /* borrowed; NULL = Style 2 */
    if (lli == NULL) {
        /* Style 2: generic measurement blocks — one per UE, each holding
         * DlRlcBufferOccupancy.LCID<n> / DlRlcHolTimeToLive.LCID<n> plus
         * the per-block SlotTimeStamp.SFN/SlotIndex/SCS.  sm_measurement_t
         * is opaque; read it through the accessors. */
        for (size_t bi = 0; bi < sm_indication_data_block_count(data); bi++) {
            const sm_measurement_block_t *block =
                sm_indication_data_block_at(data, bi);
            const char *ue = sm_ue_id_display(sm_measurement_block_ue_id(block));
            for (size_t j = 0; j < sm_measurement_block_count(block); j++) {
                const sm_measurement_t *m = sm_measurement_block_at(block, j);
                if (sm_measurement_type(m) == SM_MEAS_TYPE_INTEGER)
                    printf("  %s %s = %lld\n", ue, sm_measurement_name(m),
                           (long long)sm_measurement_integer(m));
            }
        }
        return;
    }
    /* Style 1: typed SRS / CSI copy. */
    printf("LLI sfn=%u slot=%u scs=%d\n",
           llc_lli_slot_sfn(lli), llc_lli_slot_index(lli), llc_lli_slot_scs(lli));
    if (llc_lli_kind(lli) == LLC_LLI_SRS) {
        for (size_t a = 0; a < llc_lli_srs_antenna_count(lli); a++)
            for (size_t s = 0; s < llc_lli_srs_symbol_count(lli, a); s++) {
                size_t n = 0;
                const uint8_t *raw = llc_lli_srs_raw(lli, a, s, &n);
                uint8_t comp = llc_lli_srs_compression_header(lli, a, s);
                printf("  SRS ant=%zu sym=%zu %zu bytes comp_hdr=%u\n", a, s, n, comp);
                (void)raw;
            }
    } else { /* LLC_LLI_CSI */
        for (size_t u = 0; u < llc_lli_csi_ue_count(lli); u++)
            for (size_t r = 0; r < llc_lli_csi_report_count(lli, u); r++) {
                size_t p1n = 0, p2n = 0;
                const uint8_t *p1 = llc_lli_csi_part1(lli, u, r, &p1n);
                const uint8_t *p2 = llc_lli_csi_part2(lli, u, r, &p2n); /* NULL if absent */
                printf("  CSI ue=%zu rep=%zu cfg=%ld part1=%zuB part2=%s\n",
                       u, r, llc_lli_csi_report_config_id(lli, u, r), p1n,
                       p2 ? "present" : "absent");
                (void)p1;
            }
    }
    /* lli is borrowed from data — do NOT llc_lli_free() it here. */
}

int main(void) {
    ric_xapp_config_t *cfg = ric_xapp_config_new("/etc/airpuls/xapp.yml");
    ric_xapp_config_load(cfg);

    ric_client_t *client = ric_client_connect(cfg);
    ric_client_register_sm(client, llc_plugin_get());        /* Step 1 */
    ric_client_set_on_e2_node_available(client, on_node, NULL);
    ric_client_set_on_indication(client, on_ind, NULL);
    ric_client_run(client);

    ric_client_destroy(client);
    ric_xapp_config_free(cfg);
    return 0;
}

What you will see

The periodic subscription logs one block per active UE, each with two measurements per logical channel — DlRlcBufferOccupancy.LCID<n> and DlRlcHolTimeToLive.LCID<n> — plus three per-block SlotTimeStamp.* values that locate the snapshot in time. The triggered subscription logs one line per SRS occasion or CSI report, tagged with the SFN/slot it came from and the condition ID that fired. Against the emulator you will still see CSI indications even with no live radio — the agent synthesises them (see §6).


5. The decoded data model

The branch in Step 3 splits into three concrete shapes. Each binding exposes the same accessors; only the spelling differs.

5.1 Style 2 — DL RLC buffer status

Format 2 lowers into the generic measurement-block tree: one block per UE, each block holding integer measurements named by logical channel. Per LCID there are two values:

Measurement name Meaning Units / range
DlRlcBufferOccupancy.LCID<n> downlink RLC buffer occupancy bytes
DlRlcHolTimeToLive.LCID<n> head-of-line time-to-live 0..1032 units of 0.5 ms (0..516 ms)
SlotTimeStamp.SFN System Frame Number of the snapshot 0..1023
SlotTimeStamp.SlotIndex slot within the frame per numerology
SlotTimeStamp.SCS numerology of the slot index kHz (15/30/60/120)

The two DlRlc* values repeat once per logical channel (.LCID<n>), and each block additionally carries the three SlotTimeStamp.* measurements once per block — they locate the whole snapshot in time (this is how the slot timestamp surfaces for Style 2, since the typed slot accessors of §5.4 only apply to the Style 1 LLI). The logical channel ID <n> is the spec lcID (1..32); it identifies the bearer's logical channel (signalling SRBs and data DRBs alike). A high occupancy with a small HOL-TTL is a congested bearer about to miss its delay budget — the signal a scheduler-assist xApp watches for.

RLC has no dedicated accessor

No binding exposes a typed rlc_buffer_status() method. In every language you iterate the generic measurement blocks (data.blocks / data.Blocks() / sm_indication_data_*) and read the measurement name and integer value. The name encodes the field and the LCID; parse <n> off the .LCID<n> suffix.

5.2 Style 1 — SRS

An SRS copy is a two-level array — receive antennas, and per antenna a list of symbols (size 1, 2, or 4). Each symbol carries the raw SRS bytes plus the one-byte compression header needed to interpret them:

Accessor (per antenna, symbol) Meaning
srs_antenna_count number of receive antennas in this copy
srs_symbol_count(antenna) symbols captured for that antenna
srs_raw(antenna, symbol) raw SRS PHY bytes (rawSRS OCTET STRING)
srs_compression_header(antenna, symbol) srsCompressionHeader byte — the raw SRS is uninterpretable without it

The compression header is a 1-byte OCTET STRING that describes how the gNB packed the rawSRS payload; airpuls passes both through verbatim and never interprets them. An analytics xApp must decode the raw samples against this header before using them — and because the header can differ between occasions, two raw-SRS copies are only comparable once each has been de-compressed against its own header.

5.3 Style 1 — CSI

A CSI copy is keyed per UE, each UE holding a list of reports. Each report has a config ID and a mandatory Part 1 with an optional Part 2. The wire also carries a per-UE channelCarryingUCI flag (the uplink channel — PUCCH or PUSCH — that carried the UCI), but it is not surfaced through a typed accessor in any binding today; use the presence of Part 2 as the practical discriminator (PUCCH-carried CSI has no Part 2 — see below).

Accessor (per ue, report) Meaning
csi_ue_count UEs in this copy
csi_report_count(ue) reports for that UE
csi_report_config_id(ue, report) csiReportConfigID
csi_part1(ue, report) Part 1 BIT STRING bytes (mandatory)
csi_part1_bits_unused(ue, report) trailing unused bits 0..7; valid bits = len(part1)*8 - unused
csi_part2(ue, report) Part 2 BIT STRING bytes, or None/NULL if absent
csi_part2_bits_unused(ue, report) trailing unused bits of Part 2 (0 if absent)

Because Part 1 and Part 2 are BIT STRINGs, the bits-unused count is load-bearing: the last byte is only partially significant. Always compute the valid bit length before interpreting the codeword. Part 2 is genuinely optional — for CSI carried on PUCCH the agent does not decode it, and csi_part2() returns absent.

5.4 The slot timestamp accessors

For any Style 1 indication, four accessors locate the sample in time:

Accessor Meaning
slot_sfn System Frame Number (0..1023)
slot_index slot index within the numerology
slot_scs numerology arm (LLC_SCS_15/30/60/120) — required to interpret slot_index
slot_start_time slotStartTime as the raw 64-bit NTP value (RFC 5905: high 32 bits = seconds since 1900-01-01, low 32 bits = fraction in 2⁻³² s)

The NTP-64 slotStartTime gives absolute wall-clock anchoring across E2 nodes; Python and Go additionally offer a convenience float-seconds form (slot_start_time_s / SlotStartTimeSeconds) — use the raw value where the full 2⁻³² s fraction matters. A slot index is only comparable across samples of the same slot_scs: the numerology fixes the slots-per-frame (10 at 15 kHz, 20 at 30 kHz, 40 at 60 kHz, 80 at 120 kHz), so never compare or subtract slot_index values that came from different slot_scs arms without normalising first.

sfn, slot, scs = lli.slot_sfn, lli.slot_index, lli.slot_scs
t_ntp = lli.slot_start_time_ntp        # raw u64, 0 if absent on the wire
t_s   = lli.slot_start_time_s          # float seconds since 1900-01-01
bits = len(lli.csi_part1(u, r)) * 8 - lli.csi_part1_bits_unused(u, r)
sfn, slot, scs := lli.SlotSfn(), lli.SlotIndex(), lli.SlotScs()
tNtp := lli.SlotStartTime()            // raw u64, 0 if absent on the wire
tS := lli.SlotStartTimeSeconds()       // float64 seconds since 1900-01-01
bits := len(lli.CsiPart1(u, r))*8 - int(lli.CsiPart1BitsUnused(u, r))
uint32_t sfn = llc_lli_slot_sfn(lli);
uint32_t slot = llc_lli_slot_index(lli);
llc_scs_t scs = llc_lli_slot_scs(lli);
uint64_t t_ntp = llc_lli_slot_start_time(lli);  /* raw NTP-64 */
size_t p1n = 0;
(void)llc_lli_csi_part1(lli, u, r, &p1n);
size_t bits = p1n * 8 - llc_lli_csi_part1_bits_unused(lli, u, r);

6. Advanced — a live RAN tap

Everything above works identically against the emulator and against a real gNB. This section explains how real CSI reaches your Style 1 handler, and what to tune when it does.

6.1 How live CSI flows out of the gNB

The aircell gNB image carries the airpuls E2 agent compiled into OAI's L1 PHY. When the L1 PUCCH receive thread finishes decoding a PUCCH2 CSI report, an #ifdef E2_AGENT-guarded hook copies the CSI at the hookpoint — Part 1 payload, the report config ID, the SFN/slot/SCS of the live slot, and an RFC 5905 NTP-64 timestamp captured then and there (the L1 RX thread is the single producer, so it always sees the true capture instant; the consumer never re-stamps).

The hook does no allocation and posts no ITTI message: it writes into a lock-free SPSC ring — a timestamp plus a memcpy of the payload. At each triggered-report tick the agent's LLC builder drains the ring as the single consumer, encodes the latest CSI into a Style 1 indication, and carries the live SFN/slot/SCS and NTP timestamp verbatim to the xApp. No synthetic data is injected on the live path.

sequenceDiagram
    autonumber
    participant L as gNB L1 (PUCCH RX thread)
    participant Q as lock-free SPSC ring
    participant A as E2 agent (LLC drain)
    participant R as Near-RT RIC
    participant X as xApp

    L->>L: nr_decode_pucch2() decodes CSI Part 1
    L->>Q: copy CSI + NTP-64 timestamp (stamped at the hook)
    Note over L,Q: no allocation, no ITTI — memcpy + timestamp only
    loop every triggered period
        A->>Q: fetch latest CSI (single consumer)
        alt live CSI present
            A->>R: RIC Indication (Style 1 — live SFN/slot/SCS/NTP)
        else no live CSI this period
            A->>R: RIC Indication (Style 1 — synthesised CSI)
        end
        R->>X: on_indication(... data.llc_lli())
    end

6.2 The same xApp, emulator or real gNB

When the drain finds no live CSI for a period — on the emulator, in CI, or before any UE has reported — the builder synthesises a CSI report (random 4-byte Part 1, a monotonically incrementing SFN from a per-node counter, slot index 0 at 30 kHz) and emits on schedule anyway. The live agent's synthesised fallback carries a zero slotStartTime; the standalone E2 emulator's mock stamps the emitting host's wall clock instead, so emulator samples carry a plausible, monotonic NTP-64. This is why the identical monitoring xApp from §4 runs unchanged against the emulator and against a live gNB: the indication cadence and the typed accessors are the same; only the contents differ. Because the slot fields are a counter rather than a real slot (and the emulator's timestamp is an emit-time stamp, not a true slot start), synthesised samples cannot be used for slot-accurate correlation — treat them as path-exercising filler. For PUCCH-carried CSI, Part 2 is not decoded — csi_part2() is absent on both paths.

6.3 Sizing the southbound buffer — transport.southbound.recv_bufsize

A raw SRS occasion is large. A 4×4 SRS copy can reach roughly 209 KB, which exceeds the RIC's default southbound receive slot. The transport.southbound.recv_bufsize knob (under ric: in nrtric.yml) sizes each southbound SCTP recvmmsg slot and the socket SO_RCVBUF:

  • Default: 1114112 bytes (1 MB + envelope) — comfortably above any raw-SRS copy, which is antennas × symbols × fft × 4 bytes and tops out at 512 KB for 8 antennas × 4 symbols at FFT 4096.
  • Clamp: [65536, 16 MiB], enforced at config load.
  • This is an airpuls userspace slot size, not an SCTP protocol limit — SCTP reassembles larger messages transparently; the knob sizes the buffer the RIC reads them into. The RIC keeps no reassembly buffer, so an indication larger than the slot is partial-delivered and dropped.

Raise it for wider bandwidths or higher antenna counts:

transport:
  southbound:
    recv_bufsize: 1048576   # 1 MB

Each southbound worker pre-allocates 16 slots of this size.

6.4 Watching the live data flow

The RIC exports per-SM byte-volume counters at GET /metrics, labelled by service model:

Metric Interface · direction Meaning
airpuls_sm_indications_bytes_in_total{sm="LLC"} southbound ingress (gNB → RIC) indication payload bytes (header + message) received from agents
airpuls_sm_indications_bytes_out_total{sm="LLC"} northbound egress (RIC → xApp) indication payload bytes dispatched to xApps

A rate() over either gives bytes/sec. The bundled Grafana dashboard plots them on the "Southbound Data Volume per SM (RIC ↔ E2 Node)" and "Northbound Data Volume per SM (RIC ↔ xApp)" mirror panels (per-SM, direction encoded by sign). LLC is REPORT-only, so its reverse-path Control counters (airpuls_sm_control_bytes_in_total{sm="LLC"} / airpuls_sm_control_bytes_out_total{sm="LLC"}) stay near zero — LLC shows on the indication (▲) side of both panels. When you flip from emulator to a live gNB and raise transport.southbound.recv_bufsize, these panels are where you confirm SRS-sized LLC traffic is actually flowing — a flat LLC line means no indications are reaching the RIC.


7. Reference

SDK surface

Concept Python Go C
Plugin llc_plugin_get() ric.LlcPluginGet() llc_plugin_get()
RAN Function ID LLC_RAN_FUNC_ID (5) ric.LlcRanFuncID LLC_RAN_FUNC_ID
OID LLC_OID ric.LlcOID LLC_OID
Builder (style) LlcSubscriptionBuilder(LlcStyle.STYLE_2) ric.NewLlcSubscriptionBuilder(ric.LlcStyle2) llc_subscription_builder_new(LLC_STYLE_2)
Period (Style 2) set_report_period(ms) SetReportPeriod(ms) llc_sub_set_report_period(b, ms)
Measurement (Style 2) add_measurement(LlcMeasType.…) AddMeasurement(ric.LlcMeas…) llc_sub_add_measurement(b, LLC_MEAS_…)
LLI type (Style 1) set_lli_type(LlcLliType.…) SetLliType(ric.LlcLli…) llc_sub_set_lli_type(b, LLC_LLI_…)
Trigger cond (Style 1) add_trigger_condition(id, type, logical_or=…) AddTriggerCondition(id, type, or) llc_sub_add_trigger_condition(b, id, type, or)
Subscribe client.subscribe_llc(node, b) client.SubscribeLLC(node, b) ric_client_subscribe_llc(client, node, b, &id)
Unsubscribe client.unsubscribe(sub_id) client.Unsubscribe(subID) ric_client_unsubscribe(client, id)
Typed LLI (Style 1) data.llc_lli()LlcLli/None data.Lli()*LlcLli/nil llc_indication_lli(data) → borrowed/NULL
RLC blocks (Style 2) data.blocks data.Blocks() sm_indication_data_block_at + sm_measurement_block_at

Enum values are stable across bindings: LLC_MEAS_DL_RLC_BUFFER_STATUS=0, DL_PDCP_BUFFER_STATUS=1, DL_HARQ_STATISTICS=2, SLOT_TIME_STAMP=3; LLC_LLI_SRS=0, LLC_LLI_CSI=1; LLC_SCS_15/30/60/120 = 0/1/2/3.

Running the bundled example

A complete reference xApp ships at xapps/llc-monitor/ — it subscribes to both REPORT styles, decodes SRS and CSI, and streams the lower-layer samples to the telemetry sinks. Run it natively or in Docker exactly like the other xApps:

./build_docker.sh llc-monitor
docker run -v $(pwd)/xapps/llc-monitor/xapp.yml:/etc/airpuls/xapp.yml \
    llc-monitor-python:latest

Configuration

LLC xApps use the same xapp.yml schema as every other xApp; nothing LLC-specific lives in YAML — the what/when of a subscription is expressed in code via the builder. The one RIC-side knob that matters for LLC is transport.southbound.recv_bufsize (§6), set in the RIC's nrtric.yml when you expect large raw-SRS occasions.


8. Troubleshooting — what to check

  • No LLC at all. find_ran_function(LLC_RAN_FUNC_ID) returns nothing → the node does not advertise RAN Function 5. Confirm the agent exports LLC (OID 1.3.6.1.4.1.53148.1.1.2.5, name "Lower Layers Control (airpuls)") in its E2 Setup RAN Function list.
  • data.llc_lli() is None on a Style 1 subscription. That indication was Format 2 (periodic) — a Style 1 copy always yields a typed LLI. Confirm you branched on the presence of the LLI, not on the subscription ID.
  • No Style 1 indications on a real gNB. Style 1 emission is gated at the agent-side constructor; verify the aircell build enables it and that a UE is actually sending SRS/CSI. On the emulator you should always see synthesised CSI — if even that is missing, the subscription did not arm.
  • CSI part2 is always absent. Expected for PUCCH-carried CSI — the L1 PUCCH path does not decode Part 2. Read Part 1 with its csi_part1_bits_unused count.
  • Truncated or dropped large indications. The RIC logs Dropping oversized SCTP message ... exceeds our N-byte receive slot once per oversized message. Raw 4×4 SRS occasions (~209 KB) fit the 1 MB default; higher symbol counts may not. Raise transport.southbound.recv_bufsize (§6) — the slot size is an airpuls buffer choice, not an SCTP limit.
  • Is data actually flowing? Watch rate(airpuls_sm_indications_bytes_in_total{sm="LLC"}[1m]) and the "Southbound Data Volume per SM (RIC ↔ E2 Node)" Grafana panel. A flat LLC series means no LLC indications are reaching the RIC.
  • slot_index looks wrong. It is only meaningful together with slot_scs — the numerology arm fixes the slot count per frame (15 kHz → 0..9, 120 kHz → 0..79).
  • Looking for CONTROL. LLC is REPORT-only today; CONTROL and INSERT are not implemented.