O-RAN · Non-RT RIC · WG4 M-Plane · open source (Apache-2.0)

The RIC sets the policy. A ruApp in the radio closes the gap.

An energy-saving rApp on the Non-RT RIC sets a policy envelope over standard interfaces. A ruApp in the O-RU wakes a sleeping carrier in microseconds when PRACH energy appears.

illustration, not a measurement
≈20µs
local wake · ruApp
PRACH detected: carrier ACTIVE, inside the RU
≈250ms
management-plane round trip
rApp to SMO to NETCONF to RU
measured on the running demo (Python, laptop), simulation rung, not field numbers
All whitepapersWhitepaper28 August 2026About 4 minutes to skim, 13 with the detail

In one minute

End to end

One loop covers both timescales

Policy flows right to the radio; measurements flow back on the same path.

The rApp asks the platform over R1. The SMO writes the O-RU over NETCONF/YANG (hybrid M-Plane). An O-DU path plugs into the same adapter seam. PRACH, the moment of the radio, never leaves the RU.

The closed loop · four processes
waiting
p_low (next window)
idle
local wake latency
measured on the running demo (Python, laptop)
ACTIVE · READY
txcarrier0

The closed loop. The es-rApp runs on the Non-RT RIC, the SMO has a CM adapter, and the O-RU hosts the ruApp. Policy flows right over R1 and the M-Plane: the SMO writes the O-RU over NETCONF/YANG (hybrid M-Plane). An O-DU path plugs into the same adapter seam. Notifications and PM flow back along the top. The RF twin answers the rApp before a sleep. PRACH wakes the carrier locally inside the RU.

  • control, the slow loop (R1, M-Plane)
  • the local fast loop (PRACH to ACTIVE)
  • measurements, notifications and the twin
The demo runs four processes; the ruApp runs inside the O-RU process. The figure shows the SMO's adapter seam and the interfaces between the processes, with their O-RAN names.simulation, generic 3GPP defaults (uncalibrated)
PM counters and the carrier state leave the O-RU as VES-style events (hybrid M-Plane north-bound). The SMO collects them, appends them to the twin ingest feed and delivers them to the rApp through its R1 information job.
Click a stage to jump. Animations respect your reduced-motion setting.
Where the intelligence lives

Only the place of the wake decision changes

The wake decision moves into the RU; each interface stays standard.

RIC loops run at 10 ms to 1 s (E2) and at more than 1 s (A1/O1). The moments of the radio (symbol, slot, PRACH) last microseconds.

Classical: command loop

Classical command loop. The rApp on the SMO decides each sleep and each wake. It sends each one to the RU as an edit-config, and the RU executes it. PM returns and waits for the next command. Every wake is one management-plane round trip of seconds. Traffic returns faster than the loop, so sleep stays shallow. In the animation, a command goes down and PM comes back for each wake.

  • control and policy (edit-config)
  • measurements (PM)
Left: every sleep and wake is a management-plane command.simulation, generic 3GPP defaults (uncalibrated)
Autonomous AI-RU: policy + local loop

Autonomous AI-RU, policy plus local loop. The rApp is a governor above one second. It sends one envelope, SLEEP plus wake-on-PRACH, to the RU that hosts the ruApp. Inside the RU, active equals SLEEP. PRACH energy moves the carrier to ACTIVE in microseconds on a local loop. The RU reports every transition north as a notification and PM. No round trip sits on the critical path, so deep sleep becomes safe. In the animation, the envelope goes down once and the RU wakes locally several times in the same period.

  • control and policy (the envelope)
  • the local fast loop (PRACH to ACTIVE)
  • measurements and notifications
Right: the rApp governs an envelope; the ruApp decides inside it and reports.simulation, generic 3GPP defaults (uncalibrated)
≈ 20µs
ruApp local wake
≈ 100ms
NETCONF edit-config
≈ 250ms
CM round trip

A log-scale latency axis runs from one microsecond to one second. The local ruApp wake sits near twenty microseconds. The NETCONF edit-config alone sits near one hundred milliseconds. The management-plane round trip sits near two hundred fifty milliseconds. A bracket spans the four orders of magnitude between the local wake and the round trip. In the animation, a marker sweeps the axis and lights each figure when it reaches it.

  • the local wake (ruApp, inside the RU)
  • the management plane (NETCONF, CM round trip)
The numbers come from the running demo on a laptop (Python in-process RU; a fresh NETCONF session per command). The ratio is the point, not the absolute values.rung: simulation, running demo on a laptop (Python in-process RU); not field numbers
Measurement data: physics twin

Measurement data can calibrate a physics twin

The twin is 3GPP TR 38.901 propagation plus a sector-antenna model, so tilt and on/off enter through physics.

Measurement data, where available, has two roles only. Geometry and configuration place the cells. Measured RF distributions calibrate and validate the model.

How measurement data would calibrate a physics twin. Four input types feed a TR 38.901 engine. A calibration loop fits the engine's parameters to measured RSRP and SINR quantiles, and an out-of-sample gate checks the fit. If anyone fits a calibrated twin, it stays with whoever runs the fit. The generic-defaults twin ships and serves the loop's coverage guardrail. In the animation, data flows into the engine, the calibration loop runs once, and the generic path runs on to the guardrail. The calibrated twin stays a dashed ghost.

  • measurement data and the calibrated rung
  • the generic rung (what ships)
  • never published
Data roles in the method: CM and engineering exports would define the estate. A per-device RF report, weighted by measurement-report count, becomes the calibration target. 30-minute PM gives load factors and doubles as the rApp's training set. If anyone runs a fit, the calibrated parameters stay with whoever runs it. The engine with generic 3GPP defaults ships and serves the loop.simulation, generic 3GPP defaults (uncalibrated)
Read the detail

The twin is not a black box trained on positions that we would have to invent. The engine is ~1,100 lines of numpy. What ships in this repository is the generic mode: the same engine on a synthetic hex estate with 3GPP-default parameters. The twin stamps each output with its rung, and in the public demo it says so in each response.

StepWhat happens
1 · Place
Estate from exports
Sector-carrier ARFCN gives the centre frequency and bandwidth. The engineering export gives latitude/longitude, azimuth, electrical + mechanical tilt, antenna height, beamwidth and clutter. The twin refuses nulls and never puts a default in their place: a fabricated tilt models an antenna that does not exist.
2 · Calibrate
Fit to measured marginals
The engine has ~10 free parameters: per-band EIRP, extra shadowing, indoor fraction, wall losses, interferer load, serving bias and wrap-around weight. The fit makes the predicted RSRP/SINR quantiles match the measurement-weighted quantiles from the per-device report. The method is differential evolution, then a bounded polish.
3 · Validate
Out-of-sample gate
The split of the measurements is 70/30 by device. The fit sees only the training split. Hard gates: RSRP p50 within ±2 dB, SINR p05 within ±1.5 dB, EIRP inside a physical window. Soft checks: tails within ±2 to 3 dB. The outputs of a calibrated run carry the rung simulation, calibrated to supplied measurement data, never “coverage map”, never “measured”.
4 · Use in the loop
Coverage guardrail
Before each SLEEP, the rApp asks the twin to evaluate the cell when it is off. If the coverage-rate delta breaches the budget, the sleep is blocked. In the public demo, the twin runs on a generic hex estate with 3GPP-default parameters.
# the guardrail call the rApp makes before a SLEEP
POST /v1/evaluate
{ "cell_configs": [{ "cell_id": "g-s1-a-n3", "on_off": false }] }

# reply (generic estate, uncalibrated)
"delta_vs_baseline": { "coverage_rate": -0.003, "outage_rate": 0.016,
                       "rsrp_p5": -1.198, "sinr_p5": -1.393 },
"rung": "simulation, generic 3GPP defaults (uncalibrated)"
Non-RT RIC · R1

The energy-saving rApp learns from KPI history

The rApp learns from KPI history when the next 30-minute window will be low-load.

The inputs are ordinary KPIs: PRB utilisation, throughput and connected users. The output is a policy envelope, with no per-event command.

Training the energy-saving rApp. 30-minute KPI reports become a canonical table. Features and a next-window low-load label train a gradient-boosting classifier with a time-based holdout. At run time, its probability, the current load and the twin guardrail decide the SLEEP envelope. In the animation, one comet travels the training row node by node, then crosses to the run-time row and travels it.

  • control and policy (the model and its envelope)
  • the local fast loop (what the ruApp consumes)
Offline: a vendor-neutral KPI-report adapter maps a 30-minute KPI report CSV (or the shipped synthetic generator) into a canonical table. The features are time-of-day, day-of-week, lags and rolling load. The label is “next window below the training set's 25th percentile”. Online: the classifier's probability, the current load, a hold timer and the twin guardrail decide. The result is one NETCONF edit-config that carries the envelope.simulation, generic 3GPP defaults (uncalibrated)
PRB utilisation · one generic day

A line chart of PRB utilisation over 24 hours on a generic synthetic diurnal profile. Load is low overnight, rises at midday and peaks in the evening. A dashed threshold marks the training set's 25th percentile. A shaded sleep window covers the small hours, where load sits below the threshold and p_low is high.

  • PRB utilisation, generic diurnal profile (synthetic)
  • low-load threshold, training 25th percentile
  • sleep window: p_low high and load below threshold
The shipped synthetic generator produces this profile. The identical pipeline runs locally on a 30-minute KPI report CSV; that model and its metrics stay private.simulation, generic 3GPP defaults (uncalibrated)
SettingWhat it does
p_low ≥ 0.70Sleep when the model is confident and the current load is already under the threshold.
SLEEP, never INACTIVEThe envelope keeps wake-on-demand possible. A hard off is opt-in only.
hour profileA sleeping cell reports no load. Thus the rApp forecasts from its learned hour-of-day profile and wakes the cell pre-emptively.
Read the detail

The rApp registers in the rApp Catalogue and gets PM through an ICS job (R1). The same code runs on the real O-RAN SC Non-RT RIC and on the in-process stand-in.

Synthetic data ships with the repository (a generic diurnal profile). The same pipeline can run on any 30-minute KPI report CSV in a gitignored directory. Nothing from such a run ships.

How the model is trained

The rApp uses supervised forecasting, not reinforcement learning

The rApp answers one question: “will the next 30-minute window be low-load?”

History already contains the answer for each past window, so the rApp trains on it directly. No agent explores a live network.

ApproachWhat it needsWhy not (yet)
Reinforcement learningan environment to act in, a reward signal, many episodes of trial and errorTrial and error on a live radio is not acceptable. The reward (energy vs. service) is not directly measurable today. The results are hard to audit.
Threshold / static timera night window that the operator setsIt misses the weekday/weekend shape and the cell-specific load. It gives no probability and no adaptation.
Supervised forecast + governor (used)KPI history only; a labelled window is freeIt trains in under a second and validates out of time. It is auditable. The same pipeline runs on synthetic or locally supplied KPI data.
Read the detail

This is a supervised classification problem. No one guesses a reward. You can reproduce each decision from data that you can inspect.

  1. Label: made from the data itself. For each window t, the label is true if the PRB utilisation of window t+1 falls below the 25th percentile of the training set. The threshold is relative to the data. Thus the model adapts to the load scale of each cell, and no absolute KPI value is hard-coded or published.
  2. Features: time and recent load. The features are hour of day as sine/cosine, day of week and a weekend flag. They also include current PRB utilisation DL/UL, throughput and active users. The last ones are one- and two-window lags and a rolling 3-window mean. Training and inference use the same feature function.
  3. Model: gradient-boosted trees. scikit-learn HistGradientBoostingClassifier in a pipeline with a median imputer (logistic regression is the fallback). The model returns a calibrated probability, p_low, not a yes/no. Validation is a time-based holdout of the last 20 % of windows. Thus the score shows real forecasting and no leakage from shuffled data.
  4. Governor: a deterministic policy on top. The probability goes into a rule-based policy engine. The engine sleeps a cell only when these four conditions are all true:
    • p_low ≥ 0.70
    • the current load is already under the threshold
    • the hold timer allows it
    • the twin guardrail agrees.
    It wakes a cell pre-emptively when the learned hour-of-day profile shows that load comes back. Each rule is readable, and the engine logs each decision with its inputs.

Roadmap. The physics twin is the natural offline environment. When the twin also models load, you can evaluate a policy against it, and later optimise it, without access to the network. That is the honest path to RL: simulate first, then act on the radio only inside an envelope that the governor still enforces.

# what the rApp reports about its own model
GET /health returns "model": {
  "kind": "HistGradientBoostingClassifier",
  "features": ["hour_sin","hour_cos","dow","weekend","prb_dl","prb_ul","thr_dl","users","lag1","lag2","roll3"],
  "label": "next-window prb_util_dl_pct < low_threshold (training 25th percentile)",
  "validation": "time-based holdout, last 20 %",
  "data": "synthetic" | "kpi_report_csv" (local KPI report CSV, gitignored)
}
Inside the O-RU

The ruApp decides inside a standard envelope

SLEEP means “the ruApp can wake you”. INACTIVE means “stay off”.

No new interface is necessary: WG4 o-ran-uplane-conf already defines tx-array-carriers/active ∈ {INACTIVE, SLEEP, ACTIVE}. From SLEEP, the server accepts only a change to ACTIVE. A small extension module carries the tunables in the same edit-config.

The ruApp state machine on the standard tx-array-carriers active leaf. The rApp moves the carrier from ACTIVE to SLEEP. PRACH energy lets the ruApp move the carrier from SLEEP to ACTIVE locally, in microseconds. A hold timer then rejects a new SLEEP. INACTIVE is a hard off that the ruApp never overrides. In the animation, the rApp puts the carrier to sleep and a PRACH wake brings it back. The rApp puts it to sleep again, a request for INACTIVE stops at the guard, and a pre-emptive ACTIVE closes the loop.

  • control and policy (the rApp, slow loop)
  • the local fast loop (the ruApp)
  • hard stop (INACTIVE)
The demo enforces this state machine on the carrier. The RU rejects a SLEEP request during the hold after a local wake with a standard rpc-error, so the SMO always learns why.rung: simulation; the wattages are illustrative, not measured
<!-- the rApp's envelope, as the SMO writes it over M-Plane -->
<edit-config><target><running/></target><config>
  <user-plane-configuration xmlns="urn:o-ran:uplane-conf:1.0">
    <tx-array-carriers><name>txcarrier0</name>
      <active>SLEEP</active>
    </tx-array-carriers>
  </user-plane-configuration>
  <ruapp-policy xmlns="urn:ocp:auru:ruapp-policy:1.0"><carrier>
    <name>txcarrier0</name>
    <wake-on-prach>true</wake-on-prach>
    <prach-energy-threshold-db>10</prach-energy-threshold-db>
    <min-hold-after-wake-s>30</min-hold-after-wake-s>
    <report-interval-s>5</report-interval-s>
  </carrier></ruapp-policy>
</config></edit-config>
StepWhat happens
Detect
PRACH energy
A detector on the receive path of the RU publishes an event when energy above the threshold of the envelope appears. The carrier is asleep; the receiver is not.
Decide · µs
Wake inside the envelope
The ruApp runs in-process on the event bus of the RU. SLEEP + wake-on-prach + energy ≥ threshold gives active=ACTIVE. The latency stamp is at the datastore write: no queue, no scheduler hop, no network.
Report
Every transition is visible
tx-array-carriers-state-change goes to NETCONF subscribers, and VES-style events go north. PM carries wake events and wake-latency statistics. The SMO sees each autonomous decision.
Refuse
Hold and hard-off
After a local wake, the RU rejects a new SLEEP for the hold period. From INACTIVE, the ruApp never wakes anything: the hard off of the operator stays in force.
Validation

The loop saves energy on held-out days

An offline replay of a month of 30-minute KPI history measures what the loop saves.

The first 25 days train the model. The last 5 days are held out. Each window goes through the real model, policy engine and ruApp wake rule.

  • before: always ACTIVE
  • after: es-rApp + ruApp
  • oracle: perfect foresight (upper bound)

Daily energy across five held-out days for three cases: the always-active baseline, the es-rApp plus ruApp system, and a perfect-foresight oracle. The system captures 89 percent of the achievable saving.

This chart replays the shipped defaults on the bundled synthetic estate (3 cells × 30 days, seed 42). Reproduce in the public repository: .venv/bin/python demo/scripts/eval_energy.py --data-kind synthetic --days 30 --cells 3 --seed 42. The same harness runs against any 30-minute KPI export via --data-kind kpi_report_csv.rung: simulation, synthetic estate (3 cells, 30 days, seed 42), illustrative power model
16.3%
energy saved on days the model never saw
89%
of the perfect-foresight oracle (18.4%)
0.996
classifier AUC out of sample
rung: simulation, synthetic estate (3 cells, 30 days, seed 42), illustrative power model

The row that argues for the ruApp

VariantSavedWindows slept through demandkWh saved per impact-minute
es-rApp + ruApp16.3%01.147
rApp-only (no local wake)16.7%30.196
Oracle18.4%0n/a

Without a local wake, the rApp seems to save more, but it sleeps through windows with demand. At five minutes of degraded service per wake, the ruApp version is about six times more efficient per unit of service impact.

Read the detail

It is easy to write a policy that sleeps carriers, and it is easy to fool one. Thus the replay uses the real code and not a re-implementation: the real LowLoadModel, the real policy_engine.decide() and the real ruApp wake rule. One command on the shipped synthetic data reproduces each number in this section. The efficiency ratio above is the case for the decision in the radio, as a number and not as an adjective.

What these numbers are not

LimitDetail
Power modelACTIVE ≈ 250 W + 1.5 W per % PRB, SLEEP 60 W, illustrative. KPI exports carry no energy counters. Thus the result is the ratio between before, after and oracle. The absolute kWh is not a result.
Demand proxyA window “had demand” if its 30-minute average PRB was above the low threshold. Sub-window bursts are invisible at that granularity. Thus the with-ruApp figure is an upper bound. The report prints bounds for 10 / 25 / 50 % hidden bursts.
GranularityThe replay treats one series as the carrier under control. It assumes that the remaining layer carries the residual load. The physics twin answers whether coverage really holds; this evaluation does not.
Open & reproducible

The whole loop runs on one laptop

Both profiles end with the same banner: the local wake in microseconds, the management-plane round trip in milliseconds.

lite needs only Python. full adds the real O-RAN SC Non-RT RIC containers (ICS + rApp Catalogue).

  1. Clone the repository.
    git clone https://github.com/ugeco-opensource/ocp-auRu.git
  2. Go into the repository.
    cd ocp-auRu
  3. Make a virtual environment.
    python3 -m venv .venv
  4. Install the five services with their dev extras.
    .venv/bin/pip install \
      -e "services/common[dev]" \
      -e "services/ocp-ruApp[dev]" \
      -e "services/ocp-ru[dev]" \
      -e "services/ocp-smo[dev]" \
      -e "services/ocp-rApp-es[dev]"
  5. Run the tests.
    .venv/bin/python -m pytest -q          # a few minutes
  6. Start the demo.
    demo/scripts/run_demo.sh lite          # or: full (Docker)
  7. Stop the demo.
    demo/scripts/stop_demo.sh
ComponentRoleStatus
O-RAN SC NONRTRIC (ICS, rApp Catalogue)Non-RT RIC platform, R1 servicesreal (full)
es-rAppmodel · policy · R1 client · twin guardrailreal
Physics twinTR 38.901 engine, generic estatereal engine, generic data
SMO servicesVES collector, O1-CM adapter, DME producerstand-in
SMO CM adapterNETCONF edit-config on the O-RU (hybrid M-Plane); an O-DU path plugs in through CM_ADAPTERreference adapter
O-RU M-Plane server + ruAppNETCONF/SSH, uplane-conf, notifications, PMmock RU, real protocol
Read the detail

Roadmap. The roadmap has three steps:

  1. Host the same ruApp inside the real Open M-Plane server.
  2. Drive it from real OCUDU code.
  3. Bring a reference RU under the policy, with the M-Plane to ruApp contract as a community pattern.
Scope

What ships and what does not

TopicShipsDoes not ship
RungSimulation only: generic 3GPP defaults (uncalibrated); a synthetic estate with an illustrative power model; latencies measured on the running demo (Python, laptop)Field numbers. A twin output labelled “coverage map” or “measured”.
DataSynthetic data (a generic diurnal profile), a generic hex estate and the default parametersCalibrated parameters, calibration targets, site identifiers, raw inputs and absolute KPI values. Nothing from a run on a local KPI report CSV.
CodeUnder Apache-2.0: the twin engine and its tests, the es-rApp, the SMO stand-in services and CM adapter, the mock RU with the ruAppThe roadmap items: the ruApp inside the real Open M-Plane server, a drive from real OCUDU code, a reference RU

The project makes no claim about the origin of any data used to develop, calibrate or validate it.