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 ID100. - 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.
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.