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.
In one minute
- One closed loop joins an energy-saving rApp on the Non-RT RIC to a ruApp in the O-RU.
- On the laptop demo, a local wake takes ≈20 µs; a management-plane round trip takes ≈250 ms.
- A physics twin (3GPP TR 38.901) blocks a sleep that breaks the coverage budget.
- The rApp uses a supervised forecast and a rule-based governor, without reinforcement learning.
- On 5 held-out days, the loop saves 16.3% and sleeps through no demand window (simulation, illustrative power model).
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. 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
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. 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)
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
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)
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
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.
| Step | What 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)"
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)
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
| Setting | What it does |
|---|---|
| p_low ≥ 0.70 | Sleep when the model is confident and the current load is already under the threshold. |
| SLEEP, never INACTIVE | The envelope keeps wake-on-demand possible. A hard off is opt-in only. |
| hour profile | A 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.
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.
| Approach | What it needs | Why not (yet) |
|---|---|---|
| Reinforcement learning | an environment to act in, a reward signal, many episodes of trial and error | Trial 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 timer | a night window that the operator sets | It 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 free | It 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.
- 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.
- 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.
- Model: gradient-boosted trees. scikit-learn
HistGradientBoostingClassifierin 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. - 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.
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) }
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 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>
| Step | What 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. |
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.
.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 modelThe row that argues for the ruApp
| Variant | Saved | Windows slept through demand | kWh saved per impact-minute |
|---|---|---|---|
| es-rApp + ruApp | 16.3% | 0 | 1.147 |
| rApp-only (no local wake) | 16.7% | 3 | 0.196 |
| Oracle | 18.4% | 0 | n/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
| Limit | Detail |
|---|---|
| Power model | ACTIVE ≈ 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 proxy | A 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. |
| Granularity | The 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. |
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).
- Clone the repository.
git clone https://github.com/ugeco-opensource/ocp-auRu.git
- Go into the repository.
cd ocp-auRu
- Make a virtual environment.
python3 -m venv .venv
- 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]"
- Run the tests.
.venv/bin/python -m pytest -q # a few minutes - Start the demo.
demo/scripts/run_demo.sh lite # or: full (Docker) - Stop the demo.
demo/scripts/stop_demo.sh
| Component | Role | Status |
|---|---|---|
| O-RAN SC NONRTRIC (ICS, rApp Catalogue) | Non-RT RIC platform, R1 services | real (full) |
| es-rApp | model · policy · R1 client · twin guardrail | real |
| Physics twin | TR 38.901 engine, generic estate | real engine, generic data |
| SMO services | VES collector, O1-CM adapter, DME producer | stand-in |
| SMO CM adapter | NETCONF edit-config on the O-RU (hybrid M-Plane); an O-DU path plugs in through CM_ADAPTER | reference adapter |
| O-RU M-Plane server + ruApp | NETCONF/SSH, uplane-conf, notifications, PM | mock RU, real protocol |
Read the detail
Roadmap. The roadmap has three steps:
- Host the same ruApp inside the real Open M-Plane server.
- Drive it from real OCUDU code.
- Bring a reference RU under the policy, with the M-Plane to ruApp contract as a community pattern.
What ships and what does not
| Topic | Ships | Does not ship |
|---|---|---|
| Rung | Simulation 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”. |
| Data | Synthetic data (a generic diurnal profile), a generic hex estate and the default parameters | Calibrated parameters, calibration targets, site identifiers, raw inputs and absolute KPI values. Nothing from a run on a local KPI report CSV. |
| Code | Under 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 ruApp | The 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.
