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

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.

local wake · ruApp
≈20µs
PRACH detected → carrier ACTIVE, inside the RU
management-plane round trip
≈250ms
rApp → SMO → OCUDU → NETCONF → RU
measured on the running demo (Python, laptop) — simulation rung, not field numbers
End to end

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.

state-change notifications + PM counters (VES) — hybrid M-Plane north-boundR1 data delivery (ICS job)R1O1 (CM)M-PlaneNETCONF/YANGevaluate on_off=false → Δcoveragees-rAppNon-RT RIC · energy savingmodel → p_lowpolicy → envelopeRF twinTR 38.901 physics · guardrailgeneric 3GPP defaults (public)SMO / Non-RT RIC platformrApp Catalogue · ICS (R1 DME)O1-CM adapterVES collector · twin ingestOCUDUO-CU / O-DUM-Plane clientedit-configO-RUhosts the ruAppM-Planeservero-ran-uplane-confnotifyPMruAppdecides in µstxcarrier0ACTIVE · READYPRACH detectorenergy ≥ thresholdUEPRACH energy
The demo's five processes and the interfaces between them, named as O-RAN names them. Blue = control (slow loop), copper = the local fast loop, teal = measurements and the twin.
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

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.

Classical: command looprApp decides each sleep and each wake · RU obeysrAppSMO, > 1 sRUexecutessleep (edit-config)wake (edit-config)PM waits for the next commandevery wake = one management-plane round trip (~seconds)→ traffic returns faster than the loop, so sleep stays shallowAutonomous AI-RU: policy + local looprApp sets the envelope · the ruApp decides inside itrAppgovernor, > 1 sRU + ruAppactive = SLEEPPRACH → ACTIVE (µs)envelope: SLEEP + wake-on-PRACHevery transition reported (notification + PM)no round trip on the critical path → deep sleep becomes safe
Left: every sleep and wake is a management-plane command. Right: the rApp governs an envelope; the ruApp decides inside it and reports.
1 µs10 µs100 µs1 ms10 ms100 ms1 sruApp local wake ≈ 20 µsNETCONF edit-config ≈ 100 msCM round trip ≈ 250 msfour orders of magnitude — the gap no transport fix closes
Numbers from the running demo on a laptop (Python in-process RU; fresh NETCONF session per command). The ratio is the point, not the absolute values.
Measurement data → physics twin

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.

Inputs — network management exports, where availableCM export · sector carriersARFCN → MHz, bandwidth, powerEngineering export · geometrylat/lon, azimuth, tilt, height, beam30-min PM KPI reportPRB util, throughput, users → loadPer-device RF report (MR-weighted)serving RSRP / SINR → targetsweighted quantiles p5·p25·p50·p75·p95Physics engineTR 38.901 UMa path loss (LOS/NLOS)3GPP sector pattern · tilt · O2Iserving = max RSRP · SINR w/ loadCalibration loopfit ~10 params: per-band EIRP,extra shadowing, indoor fraction,wall loss, load, serving biasDE + bounded Nelder–MeadGate (out of sample, 70/30 devices)RSRP p50 ±2 dB · SINR p05 ±1.5 dBtails ±2–3 dB (soft)Calibrated twin (not shipped)rung: simulation, calibrated to supplied measurement dataprivate — parameters, targets, geometry never shipGeneric twin (what ships)same engine · hex estate · 3GPP-default paramsrung: generic 3GPP defaults (uncalibrated)Guardrail in the loop (before SLEEP)evaluate on_off=false → Δcoverage, ΔoutageWhat ships: the engine, a synthetic hex estate and 3GPP-default parameters — the generic rung. No claim is made about the origin of any data.
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. Any calibrated parameters stay private; the engine with generic 3GPP defaults is what ships and what serves the loop.
Place

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.

Calibrate

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.

Validate

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

Use in the loop

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.

Never published
Any calibrated parameters, calibration targets, site identifiers, raw inputs and absolute KPI values. What ships: the engine, a generic estate, the default parameters and the tests. The project makes no claim about the origin of any data used to develop, calibrate or validate it.
# 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

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.

Training (offline, on 30-minute KPI history)KPI report CSV30-min canonical schema, local onlyor synthetic (shipped)Canonical KPI tableprb_util_dl/ul, thr, userscell ids synthesizedFeatures + labelhour sin/cos, dow, lags, roll-3label: next window low loadClassifierHistGradientBoostingtime-based 20 % holdoutp_low (next window)+ hour-of-day profilefor sleeping cellsRun time (every cycle, on PM delivered over R1)Policy enginep_low ≥ 0.70 ∧ load < threshold ∧ hold okTwin guardrailΔcoverage ≥ −0.05 → allowedEnvelope sent as one edit-configactive=SLEEP · wake-on-prach · threshold · hold · report intervalPRB utilisation (generic diurnal profile, synthetic)low-load threshold = training 25th percentilesleep window (p_low high, load < threshold)00:00 → 24:00
Offline: a vendor-neutral KPI-report adapter maps a 30-minute KPI report CSV (or the shipped synthetic generator) into a canonical table; 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 carrying the envelope.
p_low ≥ 0.70
sleep when the model is confident and 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, so the rApp forecasts from its learned hour-of-day profile and wakes it pre-emptively

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.

How the model is trained

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.

1 · Label

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.

2 · Features

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.

3 · Model

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.

4 · Governor

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.

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; results are hard to audit
Threshold / static timeran operator-set night windowmisses 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
Roadmap

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)
}
Inside the O-RU

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.

ACTIVEstate = READY250 W + 1.5 W/% PRB (illustrative)SLEEPstate = DISABLED · rx listens60 W (illustrative)INACTIVEhard off · no local wake20 W (illustrative)rApp: edit-config active=SLEEP + ruapp-policy (~250 ms)ruApp: PRACH energy ≥ threshold → ACTIVE (≈20 µs, local)rApp: pre-emptive ACTIVE when the hour profile says load returnsrApp: INACTIVE (only if allowed)hold after a local wake: RU answers a new SLEEP with rpc-error operation-failedStandard semantics of o-ran-uplane-conf tx-array-carriers/active: from SLEEP the server accepts only a change to ACTIVE. That rule is the envelope.
The carrier's state machine as the demo enforces it. The RU rejects a SLEEP request during the hold after a local wake with a standard rpc-error, so the SMO always learns why.
<!-- 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>
Detect

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.

Decide · µs

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.

Report

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.

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 operator's hard off is honoured.

Validation

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.

Energy per day across the five held-out days (kWh, illustrative power model)The model never saw these days. The oracle knows the future; the gap to it is what forecasting costs you.22161160−15.6%−18.5%−18.4%−15.1%−14.1%01-2601-2701-2801-2901-30before — always ACTIVEafter — es-rApp + ruApporacle — perfect foresight (upper bound)
Replay of the shipped defaults on the bundled synthetic estate (3 cells × 30 days, seed 42). Reproduce: 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.
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

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%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

Power model

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.

Demand proxy

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.

Granularity

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.

Open & reproducible

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
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
OCUDUremote-control contract → NETCONF edit-configcontract-identical shim
O-RU M-Plane server + ruAppNETCONF/SSH, uplane-conf, notifications, PMmock 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.