Skip to content

E2SM-EPI — Echo Probe Indicator

E2SM-EPI is the airpuls latency probe — a vendor SM that measures end-to-end full-trip RTT from an xApp, through the RIC, to the E2 agent and back. It exists so the RIC's latency histograms populate with real timing without any dedicated probe traffic.

Spec anchors

  • airpuls vendor SM (E2SM-EPI-v01.00-airpuls.asn) — not O-RAN-blessed.
  • OID 1.3.6.1.4.1.99999.3.1.1 (placeholder PEN pending IANA), default RAN Function ID 100.
  • Single style: CONTROL Style 1 (Echo Probe). CONTROL-only by design.

The one thing to know: it's on by default

The SDK core runs a default-on EPI probe driver. Every client fires one fire-and-forget EPI probe per xapp.node_probe_interval_ms (default 5000) at each EPI-capable E2 node. It lives in the C dispatch loop, so every binding inherits it — a plain kpm-monitor already populates the RIC's EPI latency histograms. Set the interval to 0 to disable it.

xapp:
  node_probe_interval_ms: 5000   # EPI probe cadence; 0 disables

The key is registered with a default of 5000, so an xapp.yml that never mentions it still probes. Projects generated by airpuls-sdk xapp new write 0 explicitly — turn it up if you want the RIC's latency histograms fed by that xApp.

What the probe measures, and which parts of it the xApp can read, is in The latency breakdown below.

A named exception

Unlike every other SM, EPI does not use the subscribe_<sm> / control_<sm> pattern and needs no plugin registration. It rides the generic control path, and the RIC-measured timing rides the otherwise SM-agnostic Control Ack as a nested TLV. Treat EPI as a single-purpose utility, not a template for a new SM.

Firing a probe yourself

Most xApps never call the probe API — the driver handles it. EPI has no subscribe_epi / control_epi pair: an explicit probe goes out over the generic control path, with the EPI helpers producing the header and message bytes.

from airpuls_ric_sdk import (
    EpiProbe, epi_encode_probe, epi_decode_probe_outcome,
    RicControlRequest, EPI_RAN_FUNC_ID,
)

header, message = epi_encode_probe(EpiProbe(sequence=1, payload=b""))

outcome = client.control(
    node, EPI_RAN_FUNC_ID,
    RicControlRequest(header, message, ack_required=True),
    timeout_ms=1000,
)

# RIC-measured legs ride beside the agent's opaque outcome:
t = outcome.epi_timing            # None for a non-EPI control
print(t.sb_rtt_ns, t.ric_routing_ns, t.agent_service_ns)

# The agent's own echo — sequence + self-reported service time:
if outcome.had_outcome:
    po = epi_decode_probe_outcome(outcome.outcome_bytes)
    print(po.sequence, po.agent_service_ns)

control_async works the same way and delivers the result to on_control_result_async — that is how the probe driver fires without blocking.

import "github.com/airpuls/ric-client-go/epi"

hdr, msg, err := epi.EncodeProbe(epi.Probe{Sequence: 1})
if err != nil {
    return err
}
reqID, err := client.ControlAsync(node, epi.RANFunctionID,
    hdr, msg, nil, true, 1000)

// Result arrives on the event handler:
func (h *handler) OnControlResultAsync(c *ric.Client, requestID uint32,
    outcome ric.ControlOutcome) {
    t := outcome.EpiTiming            // RIC-measured legs
    po, _ := epi.DecodeProbeOutcome(outcome.OutcomeBytes)
    log.Printf("sb=%dns routing=%dns agent=%dns seq=%d",
        t.SBRTTNs, t.RICRoutingNs, t.AgentServiceNs, po.Sequence)
}

client.RunWithProbes(intervalMs) parks the C driver and fires from the Go pump instead — use it when you want the Go-side knobs (SetProbesPerInterval, SetProbeDispatch, SetProbeMaxPending) that the benchmark xApp drives.

#include "e2sm-epi.h"

epi_probe_t probe = { .probe_type = EPI_PROBE_TYPE_ECHO,
                      .probe_sequence = 1 };
uint8_t *hdr = NULL, *msg = NULL;
size_t hdr_len = 0, msg_len = 0;
epi_control_header_encode(&probe, &hdr, &hdr_len);
epi_control_message_encode(&probe, &msg, &msg_len);

ric_control_request_t req = {
    .header_bytes = hdr,  .header_len  = hdr_len,
    .message_bytes = msg, .message_len = msg_len,
};
ric_control_outcome_t out = {0};
ric_client_control(client, node, EPI_RAN_FUNC_ID, &req, &out,
                   /*timeout_ms=*/1000);
/* out.epi_timing is NULL for a non-EPI control; for EPI it points at
 * sb_rtt_ns / ric_routing_ns / agent_service_ns.  Borrowed — valid
 * until the next call on this client. */

epi_free(hdr);
epi_free(msg);

The latency breakdown

The probe's Control Outcome carries eight timestamps across three clocks (xApp, RIC, agent), compressed into single-clock deltas, so a latency regression can be attributed to a specific leg. What reaches the xApp and what the RIC keeps are different sets:

Value Leg Where you read it
sb_rtt_ns Southbound: RIC ↔ agent outcome.epi_timing
ric_routing_ns Time routing inside the RIC outcome.epi_timing
agent_service_ns Time spent inside the agent outcome.epi_timing, and echoed in the agent's own outcome
full RTT xApp → RIC → agent → RIC → xApp measure it around the call; the RIC also computes it for its histograms
northbound RTT RIC ↔ xApp RIC-side only — airpuls_epi_nb_rtt_seconds_*

The RIC turns the whole set into per-leg histograms — see Observability → EPI latency histograms (visible under telemetry.mode: profile).

Reference

Concept Python Go C
RAN Function ID EPI_RAN_FUNC_ID (100) epi.RANFunctionID EPI_RAN_FUNC_ID
OID EPI_OID EPI_OID
Probe cadence xapp.node_probe_interval_ms (default 5000) same, or RunWithProbes(ms) xapp.node_probe_interval_ms
Encode a probe epi_encode_probe(EpiProbe(...)) epi.EncodeProbe(epi.Probe{...}) epi_control_header_encode / _message_encode
Send it client.control(node, EPI_RAN_FUNC_ID, req) client.ControlAsync(node, epi.RANFunctionID, …) ric_client_control(...)
RIC-measured legs outcome.epi_timing outcome.EpiTiming out.epi_timing
Agent echo epi_decode_probe_outcome(bytes) epi.DecodeProbeOutcome(bytes) epi_control_outcome_decode(...)

Bundled example

stress-test-epi (Go) hosts N virtual xApps, each firing EPI probes at every node, to load-test the RIC across an N × M (xApps × nodes) matrix — see xApps → Benchmark.