The RIC can decide. The radio can act. The ruApp closes the gap.
An energy-saving rApp on the Non-RT RIC sets a policy envelope over standard interfaces. A ruApp resident in the O-RU decides inside that envelope in microseconds — waking a sleeping carrier the instant PRACH energy appears — and reports every transition back. One loop, end to end, on open components, reproducible on a laptop.
One closed loop, both timescales
Policy flows right: the rApp asks the platform over R1, the SMO configures OCUDU over O1, OCUDU's M-Plane client writes the O-RU over NETCONF/YANG. Measurements flow back the same way. The radio's moment — PRACH — never leaves the RU.
Same interfaces, different roles
RIC loops run at 10 ms–1 s (E2) and above a second (A1/O1). The radio's moments — symbol, slot, PRACH — last microseconds. Moving the wake decision into the RU is the only change; every interface stays standard.
How measurement data calibrates a physics twin
The twin is not a black box trained on positions we would have to invent. It is 3GPP TR 38.901 propagation plus the sector-antenna model, written out in ~1,100 lines of numpy, so tilt and on/off enter through physics. Where measurement data is available at all, it enters in exactly two roles: geometry and configuration place the cells; measured RF distributions calibrate and validate the model. What ships in this repository is the generic mode — the same engine on a synthetic hex estate with 3GPP-default parameters. Every output is stamped with its rung.
Estate from exports
Sector-carrier ARFCN → centre frequency and bandwidth; engineering export → latitude/longitude, azimuth, electrical + mechanical tilt, antenna height, beamwidth, clutter. Nulls are refused, never defaulted — a fabricated tilt would model an antenna that does not exist.
Fit to measured marginals
The engine's ~10 free parameters (per-band EIRP, extra shadowing, indoor fraction, wall losses, interferer load, serving bias, wrap-around weight) are fitted so predicted RSRP/SINR quantiles match the measurement-weighted quantiles from the per-device report. Differential evolution, then a bounded polish.
Out-of-sample gate
Measurements are split 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; tails within ±2–3 dB as soft checks. A calibrated run's outputs carry the rung simulation, calibrated to supplied measurement data — never “coverage map”, never “measured”.
Coverage guardrail
Before every SLEEP the rApp asks the twin to evaluate the cell switched 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 and says so in every response.
# 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)"
Training the energy-saving rApp
The rApp learns when the next 30-minute window will be low-load from ordinary KPI history — PRB utilisation, throughput, connected users — and turns that into a policy envelope, not a per-event command. It registers in the rApp Catalogue and receives PM through an ICS job (R1), so 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 repo (a generic diurnal profile). The identical pipeline runs locally on a 30-minute KPI report CSV placed in a gitignored directory; that model and its metrics stay private.
Is it reinforcement learning? No — supervised forecasting plus a governor
The rApp answers one question: “will the next 30-minute window be low-load?” That is a supervised classification problem, and history already contains the answer for every past window — so we train on it directly. No agent explores a live network, no reward is guessed, and every decision is reproducible from data you can inspect.
Made from the data itself
For every window t, the label is whether window t+1's PRB utilisation falls below the training set's 25th percentile. The threshold is data-relative, so the model adapts to each cell's own load scale and no absolute KPI value is hard-coded or published.
Time and recent load
Hour of day as sine/cosine, day of week and a weekend flag, current PRB utilisation DL/UL, throughput and active users, one- and two-window lags, and a rolling 3-window mean. Exactly the same feature function is used at train and inference time.
Gradient-boosted trees
scikit-learn HistGradientBoostingClassifier in a pipeline with a median imputer (logistic regression as the fallback). It returns a calibrated probability, p_low, not a yes/no. Validation is a time-based holdout — the last 20 % of windows — so the score reflects real forecasting, not leakage from shuffled data.
Deterministic policy on top
The probability feeds a rule-based policy engine: sleep only when p_low ≥ 0.70, current load is already under the threshold, the hold timer allows it and the twin guardrail agrees; wake pre-emptively when the learned hour-of-day profile says load returns. Every rule is readable and every decision is logged with its inputs.
| 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; results are hard to audit |
| Threshold / static timer | an operator-set night window | misses weekday/weekend shape and cell-specific load; no probability, no adaptation |
| Supervised forecast + governor (used) | KPI history only; a labelled window is free | — trains in under a second, validates out of time, auditable, and the same pipeline runs on synthetic or locally supplied KPI data |
Where learning could grow
The physics twin is the natural offline environment: once it also models load, a policy can be evaluated — and later optimised — against it without touching the network. That is the honest path to RL: simulate first, act on the radio only inside an envelope the governor still enforces.
# what the rApp reports about its own model GET /health → "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: autonomy inside a standard envelope
No new interface was needed for the envelope. WG4's o-ran-uplane-conf already defines tx-array-carriers/active ∈ {INACTIVE, SLEEP, ACTIVE} and says that from SLEEP the server accepts only a change to ACTIVE. SLEEP therefore means “the ruApp may wake you”; INACTIVE means “stay off”. A small extension module carries the tunables in the same edit-config.
<!-- the rApp's envelope, as OCUDU 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:telecogeco: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>
PRACH energy
A detector on the RU's receive path publishes an event the moment energy above the envelope's threshold appears — the carrier is asleep, the receiver is not.
Wake inside the envelope
The ruApp runs in-process on the RU's event bus: SLEEP + wake-on-prach + energy ≥ threshold ⇒ active=ACTIVE. Latency is stamped at the datastore write — no queue, no scheduler hop, no network.
Every transition is visible
tx-array-carriers-state-change to NETCONF subscribers and VES-style events north; PM carries wake events and wake-latency statistics. The SMO sees every autonomous decision.
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 operator's hard off is honoured.
Does it actually save energy?
A policy that sleeps carriers is easy to write and easy to fool. So the loop is replayed offline over a month of 30-minute KPI history: the first 25 days train the model, the last 5 are held out, and every window is fed through the real LowLoadModel, the realpolicy_engine.decide() and the real ruApp wake rule — not a re-implementation. Reproduce every number below with one command on the synthetic data that ships.
cd submodules/airu && .venv/bin/python scripts/eval_energy.py --data-kind synthetic --days 30 --grid. The same harness runs against any 30-minute KPI export via --data-kind kpi_report_csv.The 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 | — |
Without a local wake the rApp appears to save more — but only by sleeping through windows that had demand. Priced at five minutes of degraded service per wake, the version with the ruApp is roughly six times more efficient per unit of service impact. That is the case for putting the decision in the radio, stated as a number rather than an adjective.
What these numbers are not
ACTIVE ≈ 250 W + 1.5 W per % PRB, SLEEP 60 W — illustrative. KPI exports carry no energy counters, so the ratio between before, after and oracle is the result; the absolute kWh is not.
A window “had demand” if its 30-minute average PRB came out above the low threshold. Sub-window bursts are invisible at that granularity, so the with-ruApp figure is an upper bound; the report prints bounds for 10 / 25 / 50 % hidden bursts.
The replay treats one series as the carrier under control and assumes residual load is carried by the remaining layer. Whether coverage really holds is the physics twin's question, not this evaluation's.
Run the whole loop on one laptop
Two profiles: lite needs only Python; full adds the real O-RAN SC Non-RT RIC containers (ICS + rApp Catalogue). Both end with the same banner: the local wake in microseconds and the management-plane round trip in milliseconds.
git clone https://github.com/telecogeco/telecogeco.git cd telecogeco && git submodule update --init --recursive cd submodules/airu python3.12 -m venv .venv && .venv/bin/pip install -r requirements.txt -e . .venv/bin/python -m pytest -q # 128 tests bash scripts/run_demo.sh lite # or: full (Docker) bash 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 |
| OCUDU | remote-control contract → NETCONF edit-config | contract-identical shim |
| O-RU M-Plane server + ruApp | NETCONF/SSH, uplane-conf, notifications, PM | mock RU, real protocol |
Roadmap: host the same ruApp inside the real Open M-Plane server, drive it from real OCUDU code, then bring a reference RU under the policy → M-Plane → ruApp contract as a community pattern.